From 9a2d6a36e273d8513ae0ed4f0f303f8d8d26c125 Mon Sep 17 00:00:00 2001 From: Laurent Guerard Date: Thu, 18 Jun 2026 13:01:32 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20Add=20EnvCLIConfig?= =?UTF-8?q?urator=20with=20dual=20conda/pixi=20launcher=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces EnvCLIConfigurator, a new base class for CLI configurators that run a Python tool in a managed environment. It supports both conda and pixi launchers via a SelectableArguments group keyed "LAUNCHER", letting the user choose at runtime in the config panel. Key additions: - EnvCLIConfigurator: dual-mode base (conda + pixi). Automatically defaults to pixi when a pixi projects root is configured. Contains CondaEnvironmentCommand and PixiEnvironmentCommand inner classes. PixiEnvironmentCommand scans a pixi project directory for available environments (.pixi/envs/). - CondaCLIConfigurator: refactored to a thin wrapper extending EnvCLIConfigurator(Launcher.CONDA) for backward compatibility. - CLIUtils: added pixi utilities — getPixiPath(), getPixiProjectsRoot(), getPixiManifestPath(), findPixiProjectsInRoot(), getPixiModuleVersion(), runInPixiEnv(). - pixipath/PixiPathConfigCommand: SciJava command for the "Configure TrackMate Pixi path…" menu entry. - pixipath/PixiDetector: detects pixi installations and lists available project environments. - CommandBuilder: updated to handle pixi translator lambdas. - Configurator: minor API additions needed by PixiEnvironmentCommand. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + .../plugin/trackmate/util/cli/CLIUtils.java | 249 ++++++++++- .../trackmate/util/cli/CommandBuilder.java | 11 +- .../util/cli/CondaCLIConfigurator.java | 173 +------- .../trackmate/util/cli/Configurator.java | 8 +- .../util/cli/EnvCLIConfigurator.java | 415 ++++++++++++++++++ .../cli/condapath/CondaPathConfigCommand.java | 12 +- .../util/cli/pixipath/PixiDetector.java | 337 ++++++++++++++ .../cli/pixipath/PixiPathConfigCommand.java | 362 +++++++++++++++ 9 files changed, 1392 insertions(+), 178 deletions(-) create mode 100644 src/main/java/fiji/plugin/trackmate/util/cli/EnvCLIConfigurator.java create mode 100644 src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiDetector.java create mode 100644 src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiPathConfigCommand.java diff --git a/.gitignore b/.gitignore index 6e8514570..43f5a57f5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ ## SciJava release tools pom.xml.releaseBackup + +# Agent file +AGENTS.md \ No newline at end of file diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/CLIUtils.java b/src/main/java/fiji/plugin/trackmate/util/cli/CLIUtils.java index 37a6a27d5..6a7bc6c77 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/CLIUtils.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/CLIUtils.java @@ -8,12 +8,12 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . @@ -58,8 +58,14 @@ public class CLIUtils public static final String CONDA_ROOT_PREFIX_KEY = "trackmate.conda.root.prefix"; + public static final String PIXI_PATH_PREF_KEY = "trackmate.pixi.path"; + + public static final String PIXI_PROJECTS_ROOT_KEY = "trackmate.pixi.projects.root"; + + private static Map< String, String > envMap; + /** * Creates and start a process that runs the command specified in the CLI. * @@ -75,7 +81,7 @@ public static final Process createProcess( final CLIConfigurator cli, final File { final List< String > cmd = CommandBuilder.build( cli ); final ProcessBuilder pb = new ProcessBuilder( cmd ); - if ( cli instanceof CondaCLIConfigurator ) + if ( cli instanceof EnvCLIConfigurator && ( ( EnvCLIConfigurator ) cli ).getLauncher() == EnvCLIConfigurator.Launcher.CONDA ) { // Env variables. final Map< String, String > env = new HashMap<>(); @@ -84,6 +90,7 @@ public static final Process createProcess( final CLIConfigurator cli, final File env.put( "CONDA_ROOT_PREFIX", condaRootPrefix ); pb.environment().putAll( env ); } + // Pixi launcher: pixi run manages env activation itself, no extra env vars needed. pb.redirectOutput( ProcessBuilder.Redirect.appendTo( logFile ) ); pb.redirectError( ProcessBuilder.Redirect.appendTo( logFile ) ); return pb.start(); @@ -341,15 +348,39 @@ public static Map< String, String > getEnvMap() throws IOException { if ( envMap == null ) { + final String condaPath = getCondaPath(); + if ( condaPath == null || condaPath.isEmpty() ) + { + envMap = new HashMap<>(); + return envMap; + } + final Path condaPathObj; + try + { + condaPathObj = Paths.get( condaPath ); + } + catch ( final InvalidPathException e ) + { + envMap = new HashMap<>(); + return envMap; + } + if ( !Files.isExecutable( condaPathObj ) ) + { + envMap = new HashMap<>(); + return envMap; + } // Prepare the command and environment variables. // Command - final ProcessBuilder pb = new ProcessBuilder( Arrays.asList( getCondaPath(), "env", "list" ) ); + final ProcessBuilder pb = new ProcessBuilder( Arrays.asList( condaPath, "env", "list" ) ); // Env variables. final Map< String, String > env = new HashMap<>(); final String condaRootPrefix = getCondaRootPrefix(); env.put( "MAMBA_ROOT_PREFIX", condaRootPrefix ); env.put( "CONDA_ROOT_PREFIX", condaRootPrefix ); pb.environment().putAll( env ); + // Pre-initialize to empty so a launch failure caches the + // result and avoids retrying on every getConfigurator() call. + envMap = new HashMap<>(); // Run and collect output. final Process process = pb.start(); final BufferedReader stdOutput = new BufferedReader( new InputStreamReader( process.getInputStream() ) ); @@ -366,7 +397,6 @@ public static Map< String, String > getEnvMap() throws IOException throw new IOException( "Could not retrieve environment map properly:\n" + errorOutput ); String line; - envMap = new HashMap<>(); while ( ( line = stdOutput.readLine() ) != null ) { line = line.trim(); @@ -424,7 +454,7 @@ public static String getCondaPath() } catch ( final IllegalArgumentException e ) { - findPath = "/usr/local/opt/micromamba/bin/micromamba"; + findPath = ""; } return prefs.get( CLIUtils.class, CLIUtils.CONDA_PATH_PREF_KEY, findPath ); } @@ -432,8 +462,27 @@ public static String getCondaPath() public static String getCondaRootPrefix() { final PrefService prefs = TMUtils.getContext().getService( PrefService.class ); - final String findPath = "/usr/local/opt/micromamba"; - return prefs.get( CLIUtils.class, CLIUtils.CONDA_ROOT_PREFIX_KEY, findPath ); + final String prefRoot = prefs.get( CLIUtils.class, CLIUtils.CONDA_ROOT_PREFIX_KEY, "" ); + if ( prefRoot != null && !prefRoot.isBlank() ) + return prefRoot; + + final String condaPath = getCondaPath(); + if ( condaPath != null && !condaPath.isBlank() ) + { + try + { + final Path path = Paths.get( condaPath ); + final Path parent = path.getParent(); + final Path parentOfParent = ( parent != null ) ? parent.getParent() : null; + if ( parentOfParent != null ) + return parentOfParent.toString(); + } + catch ( final InvalidPathException e ) + { + // Fall through to empty default. + } + } + return ""; } public static String findDefaultCondaPath() throws IllegalArgumentException @@ -452,7 +501,6 @@ public static String findDefaultCondaPath() throws IllegalArgumentException ? "/Library/micromamba/bin/micromamba" : "/.local/share/micromamba/bin/micromamba" ); final String micromamba2 = "/usr/local/micromamba/bin/micromamba"; - final String micromamba3 = "/usr/local/opt/micromamba/bin/micromamba"; final String micromamba4 = "/opt/micromamba/bin/micromamba"; final String micromamba5 = prefix + username + "/mambaforge/condabin/mamba"; final String[] toTest = new String[] { @@ -464,7 +512,6 @@ public static String findDefaultCondaPath() throws IllegalArgumentException mamba2, micromamba1, micromamba2, - micromamba3, micromamba4, micromamba5 }; @@ -611,6 +658,188 @@ public static boolean isValidPath( final String pathString ) } } + // ===================================================================== + // Pixi utilities + // ===================================================================== + + /** + * Returns the user-configured root folder that contains pixi projects as + * immediate subdirectories. Returns an empty string if not configured. + */ + public static String getPixiProjectsRoot() + { + final PrefService prefs = TMUtils.getContext().getService( PrefService.class ); + return prefs.get( CLIUtils.class, PIXI_PROJECTS_ROOT_KEY, "" ); + } + + /** + * Lists immediate subdirectories of the given root folder that contain a + * {@code pixi.toml} file. + * + * @param root + * path to the folder that contains pixi projects as + * subdirectories. + * @return sorted list of matching directories, or empty list if root is + * null/empty/missing. + */ + public static List< File > findPixiProjectsInRoot( final String root ) + { + final List< File > found = new ArrayList<>(); + if ( root == null || root.isEmpty() ) + return found; + final File rootDir = new File( root ); + if ( !rootDir.isDirectory() ) + return found; + + // If the root itself is a pixi project, include it as a candidate. + if ( new File( rootDir, "pixi.toml" ).isFile() + || new File( rootDir, "pyproject.toml" ).isFile() ) + { + found.add( rootDir ); + } + + final File[] children = rootDir.listFiles(); + if ( children == null ) + return found; + for ( final File child : children ) + { + if ( child.equals( rootDir ) ) + continue; + if ( child.isDirectory() + && ( new File( child, "pixi.toml" ).isFile() + || new File( child, "pyproject.toml" ).isFile() ) ) + found.add( child ); + } + found.sort( java.util.Comparator.comparing( File::getName ) ); + return found; + } + + /** + * Returns the path to the pixi manifest file ({@code pixi.toml} or + * {@code pyproject.toml}) inside the given project directory, or + * {@code null} if neither exists. + */ + public static String getPixiManifestPath( final String projectDir ) + { + if ( projectDir == null || projectDir.isEmpty() ) + return null; + final File pixi = new File( projectDir, "pixi.toml" ); + if ( pixi.isFile() ) + return pixi.getAbsolutePath(); + final File pyproject = new File( projectDir, "pyproject.toml" ); + if ( pyproject.isFile() ) + return pyproject.getAbsolutePath(); + return null; + } + + public static String getPixiPath() + { + final PrefService prefs = TMUtils.getContext().getService( PrefService.class ); + String findPath; + try + { + findPath = findDefaultPixiPath(); + } + catch ( final IllegalArgumentException e ) + { + findPath = System.getProperty( "user.home" ) + "/.pixi/bin/pixi"; + } + return prefs.get( CLIUtils.class, PIXI_PATH_PREF_KEY, findPath ); + } + + public static String findDefaultPixiPath() throws IllegalArgumentException + { + final String home = System.getProperty( "user.home" ); + final String pixiHome = System.getenv( "PIXI_HOME" ); + final String[] toTest = new String[] { + // User-local install (default) + home + "/.pixi/bin/pixi", + // PIXI_HOME override + ( pixiHome != null ? pixiHome + "/bin/pixi" : "" ), + // System-wide installs + "/usr/local/bin/pixi", + "/usr/bin/pixi", + "/opt/pixi/bin/pixi", + // macOS Homebrew + "/opt/homebrew/bin/pixi", + "/usr/local/opt/pixi/bin/pixi", + }; + for ( final String str : toTest ) + { + if ( str.isEmpty() ) + continue; + final Path path = Paths.get( str ); + if ( Files.isExecutable( path ) ) + return str; + } + throw new IllegalArgumentException( "Could not find a pixi executable within: " + Arrays.asList( toTest ) ); + } + + /** + * Returns the version of a Python module installed in a pixi project + * environment by running + * {@code pixi run --manifest-path /pixi.toml --environment -- python -c ...}. + * + * @param projectDir + * path to the folder containing pixi.toml. + * @param envName + * the name of the pixi environment. + * @param moduleName + * the name of the Python module. + * @return the version string, or null if it could not be + * determined. + */ + public static String getPixiModuleVersion( final String projectDir, final String envName, final String moduleName ) + { + if ( projectDir == null || projectDir.isEmpty() || envName == null || envName.isEmpty() ) + return null; + + final String manifest = getPixiManifestPath( projectDir ); + final List< String > tokens = new ArrayList<>(); + tokens.add( getPixiPath() ); + tokens.add( "run" ); + if ( manifest != null ) + { + tokens.add( "--manifest-path" ); + tokens.add( manifest ); + } + tokens.add( "--environment" ); + tokens.add( envName ); + tokens.add( "--" ); + tokens.add( "python" ); + tokens.add( "-c" ); + tokens.add( "import " + moduleName + "; print(" + moduleName + ".__version__)" ); + + final ProcessBuilder pb = new ProcessBuilder( tokens ); + pb.redirectErrorStream( true ); + try + { + final Process process = pb.start(); + final BufferedReader reader = new BufferedReader( + new InputStreamReader( process.getInputStream() ) ); + String line; + String prevLine = null; + final StringBuffer errorMsg = new StringBuffer(); + while ( ( line = reader.readLine() ) != null ) + { + prevLine = line; + errorMsg.append( '\n' + line ); + } + final int exitCode = process.waitFor(); + if ( exitCode == 0 ) + return prevLine; + else + throw new Exception( "Error running command for '" + moduleName + + "' in pixi env '" + envName + "'" + errorMsg ); + } + catch ( final Exception e ) + { + e.printStackTrace(); + return null; + } + } + + public static void main( final String[] args ) throws Exception { System.out.println( "Conda path: " + getCondaPath() ); diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/CommandBuilder.java b/src/main/java/fiji/plugin/trackmate/util/cli/CommandBuilder.java index ab006ef5c..7dcdc3458 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/CommandBuilder.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/CommandBuilder.java @@ -30,7 +30,8 @@ import org.apache.commons.lang3.StringUtils; import fiji.plugin.trackmate.util.cli.CommandCLIConfigurator.ExecutablePath; -import fiji.plugin.trackmate.util.cli.CondaCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.PixiEnvironmentCommand; import fiji.plugin.trackmate.util.cli.Configurator.AbstractStringArgument; import fiji.plugin.trackmate.util.cli.Configurator.Argument; import fiji.plugin.trackmate.util.cli.Configurator.ArgumentVisitor; @@ -82,6 +83,14 @@ public void visit( final CondaEnvironmentCommand condaEnv ) tokens.addAll( translators.getOrDefault( condaEnv, v -> Collections.singletonList( "" + v ) ).apply( condaEnv.getValue() ) ); } + @Override + public void visit( final PixiEnvironmentCommand pixiEnv ) + { + if ( pixiEnv.getValue() == null ) + throw new IllegalArgumentException( "Pixi environment is not set." ); + tokens.addAll( translators.getOrDefault( pixiEnv, v -> Collections.singletonList( "" + v ) ).apply( pixiEnv.getValue() ) ); + } + @Override public void visit( final Flag flag ) { diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/CondaCLIConfigurator.java b/src/main/java/fiji/plugin/trackmate/util/cli/CondaCLIConfigurator.java index d2adc2b52..89aea1a88 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/CondaCLIConfigurator.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/CondaCLIConfigurator.java @@ -8,12 +8,12 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . @@ -21,174 +21,27 @@ */ package fiji.plugin.trackmate.util.cli; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.CondaEnvironmentCommand; -public abstract class CondaCLIConfigurator extends CLIConfigurator +/** + * Backward-compatible base for tools that run exclusively in a conda + * environment. New tools should prefer {@link EnvCLIConfigurator} which + * supports both conda and pixi launchers via a {@code Launcher} parameter. + */ +public abstract class CondaCLIConfigurator extends EnvCLIConfigurator { - public static final String KEY_CONDA_ENV = "CONDA_ENV"; - - protected final CondaEnvironmentCommand condaEnv; - - public static class CondaEnvironmentCommand extends AbstractStringArgument< CondaEnvironmentCommand > - { - - private final List< String > envs = new ArrayList<>(); - - protected CondaEnvironmentCommand() - { - name( "Conda environment" ); - help( "The conda environment in which the tool is configured." ); - key( KEY_CONDA_ENV ); - defaultValue( "base" ); - required( true ); - } - - protected CondaEnvironmentCommand addEnvironment( final String env ) - { - if ( !envs.contains( env ) ) - envs.add( env ); - return this; - } - - @Override - public void set( final String env ) - { - if ( envs.isEmpty() ) - { - System.err.println( "The list of conda environments is empty." ); - return; - } - final int sel = envs.indexOf( env ); - if ( sel < 0 ) - { - super.set( envs.get( 0 ) ); - return; - } - super.set( env ); - } - - public void set( final int selected ) - { - if ( envs.isEmpty() ) - { - System.err.println( "The list of conda environments is empty." ); - return; - } - - if ( selected < 0 || selected >= envs.size() ) - set( envs.get( 0 ) ); - else - set( envs.get( selected ) ); - } - - public List< String > getEnvironments() - { - return envs; - } - - @Override - public void accept( final ArgumentVisitor visitor ) - { - visitor.visit( this ); - } - } + /** Kept for source compatibility; value equals {@link EnvCLIConfigurator#KEY_CONDA_ENV}. */ + public static final String KEY_CONDA_ENV = EnvCLIConfigurator.KEY_CONDA_ENV; protected CondaCLIConfigurator() { - super(); - - // Make a UI-only arg configuring the conda env. - // Default is last one (base is not interesting as a default). - final List< String > envList = new ArrayList<>(); - try - { - final List< String > l = CLIUtils.getEnvList(); - envList.addAll( l ); - } - catch ( final Exception e ) - { - // Do nothing. The list of envs will be empty. - System.err.println( "There was an error retrieving the list of conda environments.\n" - + "Did you configure Conda for TrackMate? (Edit > Options > Configure TrackMate Conda path...)" ); - e.printStackTrace(); - } - this.condaEnv = new CondaEnvironmentCommand(); - envList.forEach( condaEnv::addEnvironment ); - condaEnv.key( KEY_CONDA_ENV ); - condaEnv.set( 0 ); - - // Add the translator to make a proper cmd line calling conda first. - setCommandTranslator( condaEnv, s -> { - final List< String > cmd = new ArrayList<>(); - final String condaPath = CLIUtils.getCondaPath(); - final String os = System.getProperty( "os.name" ).toLowerCase(); - if ( os.contains( "win" ) ) - { - // In Windows: Launch a cmd.exe shell. - cmd.addAll( Arrays.asList( "cmd.exe", "/c" ) ); - } - else - { - // On Mac or Linux. - } - // Call conda run. - cmd.add( condaPath ); - cmd.add( "run" ); - cmd.add( "-n" ); - // The executable stuff. - final String envname = ( String ) s; - cmd.add( envname ); - - // The rest of the command, split by spaces. - final String executableCommand = getCommand(); - final String[] split = executableCommand.split( " " ); - cmd.addAll( Arrays.asList( split ) ); - return cmd; - } ); + super( Launcher.CONDA ); } @Override public CondaEnvironmentCommand getCommandArg() { - return condaEnv; - } - - /** - * Returns the command that must be run in the configured conda environment. - * In case the command is made of several tokens, they can be returned - * separated by space (as in a normal command line). - * - * @return the command for this tool. - */ - protected abstract String getCommand(); - - /** - * Returns the version of the Python tool that this configurator is - * configured for. This method assumes that the module has the same name - * that the CLI command used to run it. - * - * @return the version string or null if the version could not - * be determined or if the command does not run on Python. - */ - public String getVersion() - { - return getVersion( getCommandArg().getValue() ); - } - - /** - * Returns the version of the Python tool that this configurator is - * configured for. - * - * @param moduleName - * the name of the module to get the version for. - * @return the version string or null if the version could not - * be determined or if the command does not run on Python. - */ - public String getVersion( final String moduleName ) - { - return CLIUtils.getModuleVersion( condaEnv.getValue(), moduleName ); + return ( CondaEnvironmentCommand ) super.getCommandArg(); } } diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/Configurator.java b/src/main/java/fiji/plugin/trackmate/util/cli/Configurator.java index 4c6b347fc..67350089d 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/Configurator.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/Configurator.java @@ -34,7 +34,8 @@ import org.apache.commons.lang3.StringUtils; import fiji.plugin.trackmate.util.cli.CommandCLIConfigurator.ExecutablePath; -import fiji.plugin.trackmate.util.cli.CondaCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.PixiEnvironmentCommand; /** * Base class for CLI configurator tools. The implementation of a CLI @@ -265,6 +266,11 @@ public default void visit( final CondaEnvironmentCommand condaEnvironmentCommand { throw new UnsupportedOperationException(); } + + public default void visit( final PixiEnvironmentCommand pixiEnvironmentCommand ) + { + throw new UnsupportedOperationException(); + } } /* diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/EnvCLIConfigurator.java b/src/main/java/fiji/plugin/trackmate/util/cli/EnvCLIConfigurator.java new file mode 100644 index 000000000..4e21e5e11 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/util/cli/EnvCLIConfigurator.java @@ -0,0 +1,415 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2026 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.util.cli; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Common base for CLI configurators that run a tool inside a managed Python + * environment — either a conda environment or a pixi project + * environment. + *

+ * The no-arg constructor registers both env modes and exposes them via + * a {@link Configurator.SelectableArguments} group keyed {@code "LAUNCHER"}. + * The user picks conda or pixi in the config panel; settings serialization + * preserves the choice. + *

+ * The single-mode {@link #EnvCLIConfigurator(Launcher)} constructor keeps the + * old behavior for backward-compatible subclasses such as + * {@link CondaCLIConfigurator}. + * + * @author Jean-Yves Tinevez, Laurent Guerard + */ +public abstract class EnvCLIConfigurator extends CLIConfigurator +{ + + public enum Launcher + { + CONDA, PIXI + } + + public static final String KEY_CONDA_ENV = "CONDA_ENV"; + + public static final String KEY_PIXI_ENV = "PIXI_ENV"; + + public static final String KEY_PIXI_PROJECT = "PIXI_PROJECT"; + + /** Key stored in the settings map to identify the active launcher. */ + public static final String KEY_LAUNCHER = "LAUNCHER"; + + // ========================================================================= + // CondaEnvironmentCommand + // ========================================================================= + + public static class CondaEnvironmentCommand extends AbstractStringArgument< CondaEnvironmentCommand > + { + + private final List< String > envs = new ArrayList<>(); + + protected CondaEnvironmentCommand() + { + name( "Conda environment" ); + help( "The conda environment in which the tool is configured." ); + key( KEY_CONDA_ENV ); + defaultValue( "base" ); + required( true ); + } + + protected CondaEnvironmentCommand addEnvironment( final String env ) + { + if ( !envs.contains( env ) ) + envs.add( env ); + return this; + } + + @Override + public void set( final String env ) + { + if ( envs.isEmpty() ) + { + super.set( env ); + return; + } + final int sel = envs.indexOf( env ); + if ( sel < 0 ) + { + super.set( envs.get( 0 ) ); + return; + } + super.set( env ); + } + + public void set( final int selected ) + { + if ( envs.isEmpty() ) + return; + if ( selected < 0 || selected >= envs.size() ) + set( envs.get( 0 ) ); + else + set( envs.get( selected ) ); + } + + public List< String > getEnvironments() + { + return envs; + } + + @Override + public void accept( final ArgumentVisitor visitor ) + { + visitor.visit( this ); + } + } + + // ========================================================================= + // PixiEnvironmentCommand + // ========================================================================= + + public static class PixiEnvironmentCommand extends AbstractStringArgument< PixiEnvironmentCommand > + { + + private final PathArgument projectPathArg; + + private final List< String > envs = new ArrayList<>(); + + protected PixiEnvironmentCommand( final PathArgument projectPathArg ) + { + this.projectPathArg = projectPathArg; + name( "Pixi environment" ); + help( "The environment within the pixi project to run the tool in." ); + key( KEY_PIXI_ENV ); + defaultValue( "" ); + required( true ); + } + + public PathArgument getProjectPathArg() + { + return projectPathArg; + } + + public void refreshEnvs() + { + envs.clear(); + final String projectDir = projectPathArg.getValue(); + if ( projectDir == null || projectDir.isEmpty() ) + return; + final Path envsPath = Paths.get( projectDir, ".pixi", "envs" ); + if ( Files.isDirectory( envsPath ) ) + { + try + { + Files.list( envsPath ).forEach( p -> { + if ( Files.isDirectory( p ) ) + envs.add( p.getFileName().toString() ); + } ); + envs.sort( null ); + } + catch ( final IOException e ) + { + System.err.println( "Could not list pixi environments in: " + envsPath ); + } + } + } + + public List< String > getEnvironments() + { + return envs; + } + + @Override + public void set( final String env ) + { + // Always accept without validating against envs — envs may be empty + // or from a different project at deserialization time. + // refreshEnvCombo() corrects invalid values after envs reload. + super.set( env ); + } + + public void set( final int selected ) + { + if ( envs.isEmpty() ) + return; + if ( selected < 0 || selected >= envs.size() ) + set( envs.get( 0 ) ); + else + set( envs.get( selected ) ); + } + + @Override + public void accept( final ArgumentVisitor visitor ) + { + visitor.visit( this ); + } + } + + // ========================================================================= + // EnvCLIConfigurator + // ========================================================================= + + private final CondaEnvironmentCommand condaCmd; + + private final PixiEnvironmentCommand pixiCmd; + + /** Non-null only in dual-mode (no-arg constructor). */ + private final SelectableArguments selectableLauncher; + + /** + * Dual-mode constructor. Registers both conda and pixi env commands in the + * arguments list and exposes them as a {@link SelectableArguments} group. + * The user can pick conda or pixi in the config panel. + */ + protected EnvCLIConfigurator() + { + super(); + this.condaCmd = setupConda(); + this.pixiCmd = setupPixi(); + + // Mark env commands as not CLI args (prefix is built via getCommandArg). + condaCmd.inCLI( false ); + pixiCmd.inCLI( false ); + + // Insert condaCmd before pixiProject (already added by addPathArgument). + arguments.add( 0, condaCmd ); + arguments.add( pixiCmd ); + + this.selectableLauncher = addSelectableArguments() + .add( condaCmd ) + .add( pixiCmd ) + .key( KEY_LAUNCHER ); + + // Default to pixi when a projects root is already configured. + try + { + if ( !CLIUtils.getPixiProjectsRoot().isEmpty() ) + selectableLauncher.select( 1 ); + } + catch ( final Exception e ) + { + // Context not yet available; keep default (conda). + } + } + + /** + * Single-mode constructor for backward-compatible subclasses (e.g. + * {@link CondaCLIConfigurator}). Only the specified launcher is registered; + * no {@link SelectableArguments} is created. + * + * @param launcher + * the fixed launcher for this configurator. + */ + protected EnvCLIConfigurator( final Launcher launcher ) + { + super(); + this.selectableLauncher = null; + if ( launcher == Launcher.CONDA ) + { + this.condaCmd = setupConda(); + this.pixiCmd = null; + } + else + { + this.condaCmd = null; + this.pixiCmd = setupPixi(); + } + } + + private CondaEnvironmentCommand setupConda() + { + final List< String > envList = new ArrayList<>(); + try + { + envList.addAll( CLIUtils.getEnvList() ); + } + catch ( final Exception e ) + { + System.err.println( "There was an error retrieving the list of conda environments.\n" + + "Did you configure Conda or Pixi for TrackMate? (Edit > Options > Configure TrackMate Conda path... or " + + "Configure TrackMate Pixi path...)" ); + e.printStackTrace(); + } + final CondaEnvironmentCommand cmd = new CondaEnvironmentCommand(); + envList.forEach( cmd::addEnvironment ); + cmd.key( KEY_CONDA_ENV ); + if ( envList.isEmpty() ) + cmd.set( "base" ); // Ensure non-null value when conda is not configured + else + cmd.set( 0 ); + + setCommandTranslator( cmd, s -> { + final List< String > tokens = new ArrayList<>(); + final String condaPath = CLIUtils.getCondaPath(); + final String os = System.getProperty( "os.name" ).toLowerCase(); + if ( os.contains( "win" ) ) + tokens.addAll( Arrays.asList( "cmd.exe", "/c" ) ); + tokens.add( condaPath ); + tokens.add( "run" ); + tokens.add( "-n" ); + tokens.add( ( String ) s ); + final String[] split = getCommand().split( " " ); + tokens.addAll( Arrays.asList( split ) ); + return tokens; + } ); + return cmd; + } + + private PixiEnvironmentCommand setupPixi() + { + final PathArgument pixiProject = addPathArgument() + .name( "Pixi project folder" ) + .help( "Folder containing the pixi.toml file for this tool." ) + .key( KEY_PIXI_PROJECT ) + .defaultValue( "" ) + .inCLI( false ) + .visible( false ) + .get(); + + final PixiEnvironmentCommand cmd = new PixiEnvironmentCommand( pixiProject ); + cmd.key( KEY_PIXI_ENV ); + + setCommandTranslator( cmd, s -> { + final List< String > tokens = new ArrayList<>(); + final String envName = ( String ) s; + final String projectDir = pixiProject.getValue() != null ? pixiProject.getValue() : ""; + final String pixiExe = CLIUtils.getPixiPath(); + tokens.add( pixiExe ); + tokens.add( "run" ); + if ( !projectDir.isEmpty() ) + { + final String manifest = CLIUtils.getPixiManifestPath( projectDir ); + if ( manifest != null ) + { + tokens.add( "--manifest-path" ); + tokens.add( manifest ); + } + } + if ( envName != null && !envName.isEmpty() ) + { + tokens.add( "--environment" ); + tokens.add( envName ); + } + tokens.add( "--" ); + for ( final String token : getCommand().split( " " ) ) + tokens.add( token ); + return tokens; + } ); + return cmd; + } + + public Launcher getLauncher() + { + if ( selectableLauncher != null ) + return selectableLauncher.getSelected() == 0 ? Launcher.CONDA : Launcher.PIXI; + return condaCmd != null ? Launcher.CONDA : Launcher.PIXI; + } + + @Override + public Argument< ?, ? > getCommandArg() + { + if ( selectableLauncher != null ) + return selectableLauncher.getSelection(); + return condaCmd != null ? condaCmd : pixiCmd; + } + + /** + * Returns the command that must be run in the configured environment. If + * the command consists of several tokens, separate them with spaces. + * + * @return the command for this tool. + */ + protected abstract String getCommand(); + + /** + * Returns the version of the Python tool, assuming the module name matches + * the first token of {@link #getCommand()}. + * + * @return the version string, or {@code null} if not determinable. + */ + public String getVersion() + { + return getVersion( getCommand().split( " " )[ 0 ] ); + } + + /** + * Returns the version of the specified Python module inside the configured + * environment. + * + * @param moduleName + * the Python module name. + * @return the version string, or {@code null} if not determinable. + */ + public String getVersion( final String moduleName ) + { + if ( getLauncher() == Launcher.CONDA ) + return CLIUtils.getModuleVersion( + condaCmd.getValue(), moduleName ); + return CLIUtils.getPixiModuleVersion( + pixiCmd.getProjectPathArg().getValue(), + pixiCmd.getValue(), + moduleName ); + } +} diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/condapath/CondaPathConfigCommand.java b/src/main/java/fiji/plugin/trackmate/util/cli/condapath/CondaPathConfigCommand.java index a412a14a5..449b7ec27 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/condapath/CondaPathConfigCommand.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/condapath/CondaPathConfigCommand.java @@ -8,12 +8,12 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . @@ -90,14 +90,14 @@ private void createAndShowDialog() } catch ( final IllegalArgumentException e ) { - findPath = "/usr/local/opt/micromamba/bin/micromamba"; + findPath = ""; } final String condaPath = prefs.get( CLIUtils.class, CLIUtils.CONDA_PATH_PREF_KEY, findPath ); - final Path path = Paths.get( condaPath ); - final Path parent = path.getParent(); + final Path path = ( condaPath != null && !condaPath.isBlank() ) ? Paths.get( condaPath ) : null; + final Path parent = ( path != null ) ? path.getParent() : null; final Path parentOfParent = ( parent != null ) ? parent.getParent() : null; - final String defaultValue = "/usr/local/opt/micromamba/"; + final String defaultValue = ""; String condaRootPrefix = ( parentOfParent != null ) ? parentOfParent.toString() diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiDetector.java b/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiDetector.java new file mode 100644 index 000000000..f76bf7e32 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiDetector.java @@ -0,0 +1,337 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2026 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.util.cli.pixipath; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import ij.IJ; + +/** + * Detects pixi installation and provides information needed to run pixi + * commands. + */ +public class PixiDetector +{ + + private static PixiInfo cachedInfo = null; + + private static long cacheTimestamp = 0; + + private static final long CACHE_TIMEOUT_MS = 60000; + + public static class PixiInfo + { + private final String pixiExecutable; + + private final String envsRoot; + + private final String version; + + public PixiInfo( final String pixiExecutable, final String envsRoot, final String version ) + { + this.pixiExecutable = pixiExecutable; + this.envsRoot = envsRoot; + this.version = version; + } + + public String getPixiExecutable() + { + return pixiExecutable; + } + + public String getEnvsRoot() + { + return envsRoot; + } + + public String getVersion() + { + return version; + } + + @Override + public String toString() + { + return String.format( "PixiInfo{executable='%s', envsRoot='%s', version='%s'}", + pixiExecutable, envsRoot, version ); + } + } + + public static class PixiNotFoundException extends Exception + { + private static final long serialVersionUID = 1L; + + public PixiNotFoundException( final String message ) + { + super( message ); + } + } + + public static PixiInfo detect() throws PixiNotFoundException + { + final long now = System.currentTimeMillis(); + if ( cachedInfo != null && ( now - cacheTimestamp ) < CACHE_TIMEOUT_MS ) + return cachedInfo; + + final PixiInfo info = detectPixiInfo(); + if ( info != null ) + { + cachedInfo = info; + cacheTimestamp = now; + return info; + } + + throw new PixiNotFoundException( + "Could not find pixi installation.\n" + + "Please install pixi from https://pixi.sh or set the path manually." ); + } + + public static void clearCache() + { + cachedInfo = null; + cacheTimestamp = 0; + } + + private static PixiInfo detectPixiInfo() + { + IJ.log( "Starting pixi detection..." ); + + // Method 1: PIXI_HOME env var + final String pixiHome = System.getenv( "PIXI_HOME" ); + if ( pixiHome != null && !pixiHome.isEmpty() ) + { + IJ.log( "Method 1: Checking PIXI_HOME environment variable..." ); + final String exe = isWindows() + ? pixiHome + "\\bin\\pixi.exe" + : pixiHome + "/bin/pixi"; + if ( new File( exe ).canExecute() ) + { + final String version = getPixiVersion( exe ); + if ( version != null ) + { + final String envsRoot = pixiHome + ( isWindows() ? "\\envs" : "/envs" ); + IJ.log( " Found pixi via PIXI_HOME: " + exe ); + return new PixiInfo( exe, envsRoot, version ); + } + } + } + + // Method 2: system PATH + IJ.log( "Method 2: Searching system PATH..." ); + final String inPath = findInPath( isWindows() ? "pixi.exe" : "pixi" ); + if ( inPath != null ) + { + final String version = getPixiVersion( inPath ); + if ( version != null ) + { + final String home = System.getProperty( "user.home" ); + final String envsRoot = home + ( isWindows() ? "\\.pixi\\envs" : "/.pixi/envs" ); + IJ.log( " Found pixi in PATH: " + inPath ); + return new PixiInfo( inPath, envsRoot, version ); + } + } + + // Method 3: common locations + IJ.log( "Method 3: Checking common installation locations..." ); + final String home = System.getProperty( "user.home" ); + final String[] candidates = isWindows() + ? new String[] { + home + "\\.pixi\\bin\\pixi.exe", + "C:\\tools\\pixi\\pixi.exe", + } + : new String[] { + home + "/.pixi/bin/pixi", + "/usr/local/bin/pixi", + "/usr/bin/pixi", + "/opt/pixi/bin/pixi", + "/opt/homebrew/bin/pixi", + "/usr/local/opt/pixi/bin/pixi", + }; + + for ( final String candidate : candidates ) + { + if ( new File( candidate ).canExecute() ) + { + final String version = getPixiVersion( candidate ); + if ( version != null ) + { + final String envsRoot = home + ( isWindows() ? "\\.pixi\\envs" : "/.pixi/envs" ); + IJ.log( " Found pixi at: " + candidate ); + return new PixiInfo( candidate, envsRoot, version ); + } + } + } + + IJ.log( "Failed to detect pixi installation." ); + return null; + } + + private static String getPixiVersion( final String pixiExePath ) + { + try + { + final List< String > command = new ArrayList<>(); + if ( isWindows() && !pixiExePath.endsWith( ".exe" ) ) + { + command.add( "cmd.exe" ); + command.add( "/c" ); + } + command.add( pixiExePath ); + command.add( "--version" ); + + final ProcessBuilder pb = new ProcessBuilder( command ); + pb.redirectErrorStream( true ); + final Process process = pb.start(); + final String output = readProcessOutput( process ); + final boolean completed = process.waitFor( 5, TimeUnit.SECONDS ); + + if ( completed && process.exitValue() == 0 && output != null ) + return output.trim().replace( "pixi", "" ).trim(); + } + catch ( final Exception e ) + { + IJ.log( "Failed to get pixi version from " + pixiExePath + ": " + e.getMessage() ); + } + return null; + } + + private static String findInPath( final String executable ) + { + final String[] command = isWindows() + ? new String[] { "where", executable } + : new String[] { "which", executable }; + try + { + final ProcessBuilder pb = new ProcessBuilder( command ); + pb.redirectErrorStream( true ); + final Process process = pb.start(); + final String output = readProcessOutput( process ); + final boolean completed = process.waitFor( 5, TimeUnit.SECONDS ); + if ( !completed || process.exitValue() != 0 ) + return null; + if ( output != null && !output.isEmpty() ) + { + final String path = output.split( "\n" )[ 0 ].trim(); + if ( new File( path ).exists() ) + return path; + } + } + catch ( final Exception e ) + { + IJ.log( "Error searching PATH for pixi: " + e.getMessage() ); + } + return null; + } + + private static String readProcessOutput( final Process process ) throws IOException + { + final StringBuilder output = new StringBuilder(); + try (final BufferedReader reader = new BufferedReader( + new InputStreamReader( process.getInputStream() ) )) + { + String line; + while ( ( line = reader.readLine() ) != null ) + { + if ( output.length() > 0 ) + output.append( "\n" ); + output.append( line ); + } + } + return output.toString(); + } + + private static boolean isWindows() + { + return System.getProperty( "os.name" ).toLowerCase().contains( "win" ); + } + + /** + * Lists all environments found in the pixi global envs directory. + */ + public static List< String > findGlobalEnvironments( final String envsRoot ) + { + final List< String > envs = new ArrayList<>(); + final Path envsPath = Paths.get( envsRoot ); + if ( Files.isDirectory( envsPath ) ) + { + try + { + Files.list( envsPath ).forEach( p -> { + if ( Files.isDirectory( p ) ) + envs.add( p.getFileName().toString() ); + } ); + } + catch ( final IOException e ) + { + IJ.log( "Could not list pixi environments: " + e.getMessage() ); + } + } + envs.sort( null ); + return envs; + } + + public static void diagnose() + { + IJ.log( "╔════════════════════════════════════════╗" ); + IJ.log( "║ Pixi Detection Diagnosis System ║" ); + IJ.log( "╚════════════════════════════════════════╝" ); + IJ.log( "" ); + IJ.log( "System Information:" ); + IJ.log( " OS: " + System.getProperty( "os.name" ) ); + IJ.log( " User Home: " + System.getProperty( "user.home" ) ); + IJ.log( " PIXI_HOME: " + System.getenv( "PIXI_HOME" ) ); + IJ.log( "" ); + + try + { + final PixiInfo info = detect(); + IJ.log( "✅ Pixi detected successfully!" ); + IJ.log( " Executable: " + info.getPixiExecutable() ); + IJ.log( " Envs root: " + info.getEnvsRoot() ); + IJ.log( " Version: " + info.getVersion() ); + IJ.log( "" ); + final List< String > envs = findGlobalEnvironments( info.getEnvsRoot() ); + IJ.log( "Global environments (" + envs.size() + "):" ); + for ( final String env : envs ) + IJ.log( " • " + env ); + } + catch ( final PixiNotFoundException e ) + { + IJ.log( "❌ Pixi not found: " + e.getMessage() ); + } + } + + public static void main( final String[] args ) + { + diagnose(); + } +} diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiPathConfigCommand.java b/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiPathConfigCommand.java new file mode 100644 index 000000000..d4a16b317 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/util/cli/pixipath/PixiPathConfigCommand.java @@ -0,0 +1,362 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2026 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.util.cli.pixipath; + +import static fiji.plugin.trackmate.gui.Icons.TRACKMATE_ICON; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.Font; +import java.io.File; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import javax.swing.border.EmptyBorder; + +import org.scijava.command.Command; +import org.scijava.command.CommandService; +import org.scijava.plugin.Plugin; +import org.scijava.prefs.PrefService; + +import fiji.plugin.trackmate.gui.Fonts; +import fiji.plugin.trackmate.gui.GuiUtils; +import fiji.plugin.trackmate.gui.Icons; +import fiji.plugin.trackmate.util.TMUtils; +import fiji.plugin.trackmate.util.cli.CLIUtils; +import fiji.plugin.trackmate.util.cli.pixipath.PixiDetector.PixiInfo; +import fiji.plugin.trackmate.util.cli.pixipath.PixiDetector.PixiNotFoundException; +import ij.IJ; +import ij.ImageJ; + +@Plugin( type = Command.class, + label = "Configure the path to the Pixi executable used in TrackMate...", + iconPath = "/icons/commands/information.png", + menuPath = "Edit > Options > Configure TrackMate Pixi path..." ) +public class PixiPathConfigCommand implements Command +{ + + @Override + public void run() + { + SwingUtilities.invokeLater( () -> createAndShowDialog() ); + } + + private void createAndShowDialog() + { + final PrefService prefs = TMUtils.getContext().getService( PrefService.class ); + + String findPath; + try + { + findPath = CLIUtils.findDefaultPixiPath(); + } + catch ( final IllegalArgumentException e ) + { + findPath = System.getProperty( "user.home" ) + "/.pixi/bin/pixi"; + } + final String pixiPath = prefs.get( CLIUtils.class, CLIUtils.PIXI_PATH_PREF_KEY, findPath ); + final String projectsRoot = prefs.get( CLIUtils.class, CLIUtils.PIXI_PROJECTS_ROOT_KEY, "" ); + + final JDialog dialog = new JDialog( IJ.getInstance(), "TrackMate Pixi Configuration", false ); + dialog.setIconImage( TRACKMATE_ICON.getImage() ); + dialog.setDefaultCloseOperation( WindowConstants.DISPOSE_ON_CLOSE ); + + final JPanel mainPanel = new JPanel( new BorderLayout( 10, 10 ) ); + mainPanel.setBorder( new EmptyBorder( 15, 15, 15, 15 ) ); + mainPanel.setBackground( Color.WHITE ); + + mainPanel.add( createHeaderPanel(), BorderLayout.NORTH ); + + final JPanel centerPanel = new JPanel(); + centerPanel.setLayout( new BoxLayout( centerPanel, BoxLayout.Y_AXIS ) ); + centerPanel.setBackground( Color.WHITE ); + + final JTextArea statusArea = new JTextArea( 2, 50 ); + statusArea.setEditable( false ); + statusArea.setLineWrap( true ); + statusArea.setWrapStyleWord( true ); + statusArea.setFont( new Font( "SansSerif", Font.PLAIN, 11 ) ); + statusArea.setForeground( new Color( 60, 120, 180 ) ); + statusArea.setBackground( new Color( 240, 248, 255 ) ); + statusArea.setBorder( BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder( new Color( 180, 200, 220 ) ), + new EmptyBorder( 5, 8, 5, 8 ) ) ); + statusArea.setText( "Configure pixi paths below" ); + + final JScrollPane statusScrollPane = new JScrollPane( statusArea ); + statusScrollPane.setBorder( BorderFactory.createEmptyBorder() ); + centerPanel.add( statusScrollPane ); + centerPanel.add( Box.createVerticalStrut( 15 ) ); + + final JPanel execPanel = createPathPanel( + "Pixi Executable Path", + "Path to the pixi executable (e.g. ~/.pixi/bin/pixi)", + pixiPath, + false ); + final JTextField execField = ( JTextField ) execPanel.getClientProperty( "textfield" ); + final JButton execBrowseButton = ( JButton ) execPanel.getClientProperty( "browse" ); + centerPanel.add( execPanel ); + centerPanel.add( Box.createVerticalStrut( 10 ) ); + + final JPanel rootPanel = createPathPanel( + "Pixi Projects Root", + "Folder whose subdirectories are pixi projects (each contains a pixi.toml). Used by the \"find\" button in detector panels.", + projectsRoot, + true ); + final JTextField rootField = ( JTextField ) rootPanel.getClientProperty( "textfield" ); + final JButton rootBrowseButton = ( JButton ) rootPanel.getClientProperty( "browse" ); + centerPanel.add( rootPanel ); + centerPanel.add( Box.createVerticalStrut( 15 ) ); + + execBrowseButton.addActionListener( e -> browseFor( execField, dialog, false ) ); + rootBrowseButton.addActionListener( e -> browseFor( rootField, dialog, true ) ); + + mainPanel.add( centerPanel, BorderLayout.CENTER ); + + final JPanel buttonPanel = new JPanel( new FlowLayout( FlowLayout.RIGHT, 10, 0 ) ); + buttonPanel.setBackground( Color.WHITE ); + + final JButton autoDetectButton = new JButton( "Auto-detect" ); + autoDetectButton.setIcon( Icons.PREVIEW_ICON ); + autoDetectButton.addActionListener( e -> autoDetect( execField, statusArea ) ); + + final JButton diagnoseButton = new JButton( "Diagnose" ); + diagnoseButton.setIcon( Icons.COG_ICON ); + diagnoseButton.addActionListener( e -> diagnose() ); + + final JButton testButton = new JButton( "Test" ); + testButton.setIcon( Icons.EXECUTE_ICON ); + testButton.addActionListener( e -> test( execField.getText(), statusArea ) ); + + final JButton okButton = new JButton( "OK" ); + okButton.addActionListener( e -> saveAndClose( execField.getText(), rootField.getText(), prefs, dialog ) ); + + final JButton cancelButton = new JButton( "Cancel" ); + cancelButton.addActionListener( e -> dialog.dispose() ); + + buttonPanel.add( autoDetectButton ); + buttonPanel.add( diagnoseButton ); + buttonPanel.add( testButton ); + buttonPanel.add( Box.createHorizontalStrut( 20 ) ); + buttonPanel.add( okButton ); + buttonPanel.add( cancelButton ); + + mainPanel.add( buttonPanel, BorderLayout.SOUTH ); + + dialog.add( mainPanel ); + dialog.pack(); + dialog.setLocationRelativeTo( IJ.getInstance() ); + dialog.setVisible( true ); + } + + private JPanel createHeaderPanel() + { + final JPanel headerPanel = new JPanel( new BorderLayout( 10, 5 ) ); + headerPanel.setBackground( Color.WHITE ); + + final JLabel iconLabel = new JLabel( GuiUtils.scaleImage( Icons.TRACKMATE_ICON, 48, 48 ) ); + headerPanel.add( iconLabel, BorderLayout.WEST ); + + final JPanel textPanel = new JPanel(); + textPanel.setLayout( new BoxLayout( textPanel, BoxLayout.Y_AXIS ) ); + textPanel.setBackground( Color.WHITE ); + + final JLabel titleLabel = new JLabel( "Pixi Configuration" ); + titleLabel.setFont( Fonts.BIG_FONT ); + titleLabel.setAlignmentX( Component.LEFT_ALIGNMENT ); + + final JLabel subtitleLabel = new JLabel( "Configure pixi executable for TrackMate" ); + subtitleLabel.setFont( Fonts.SMALL_FONT ); + subtitleLabel.setForeground( Color.GRAY ); + subtitleLabel.setAlignmentX( Component.LEFT_ALIGNMENT ); + + textPanel.add( titleLabel ); + textPanel.add( Box.createVerticalStrut( 3 ) ); + textPanel.add( subtitleLabel ); + + headerPanel.add( textPanel, BorderLayout.CENTER ); + headerPanel.add( Box.createVerticalStrut( 10 ), BorderLayout.SOUTH ); + + return headerPanel; + } + + private JPanel createPathPanel( final String title, final String description, final String defaultPath, final boolean directory ) + { + final JPanel panel = new JPanel( new BorderLayout( 5, 5 ) ); + panel.setBackground( Color.WHITE ); + panel.setBorder( BorderFactory.createCompoundBorder( + BorderFactory.createTitledBorder( title ), + new EmptyBorder( 5, 5, 5, 5 ) ) ); + + final JLabel descLabel = new JLabel( description ); + descLabel.setFont( Fonts.SMALL_FONT ); + descLabel.setForeground( Color.GRAY ); + panel.add( descLabel, BorderLayout.NORTH ); + + final JPanel inputPanel = new JPanel( new BorderLayout( 5, 0 ) ); + inputPanel.setBackground( Color.WHITE ); + + final JTextField textField = new JTextField( defaultPath, 40 ); + textField.setFont( new Font( "Monospaced", Font.PLAIN, 12 ) ); + + final JButton browseButton = new JButton( "Browse..." ); + browseButton.setFocusable( false ); + + inputPanel.add( textField, BorderLayout.CENTER ); + inputPanel.add( browseButton, BorderLayout.EAST ); + + panel.add( inputPanel, BorderLayout.CENTER ); + panel.putClientProperty( "textfield", textField ); + panel.putClientProperty( "browse", browseButton ); + + return panel; + } + + private void browseFor( final JTextField textField, final JDialog parent, final boolean directory ) + { + final JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode( directory ? JFileChooser.DIRECTORIES_ONLY : JFileChooser.FILES_ONLY ); + chooser.setDialogTitle( directory ? "Select pixi projects root folder" : "Select pixi executable" ); + + final String currentPath = textField.getText(); + if ( !currentPath.isEmpty() ) + { + final File current = new File( currentPath ); + final File startDir = directory ? current : ( current.getParentFile() != null ? current.getParentFile() : current ); + if ( startDir.exists() ) + chooser.setCurrentDirectory( startDir ); + } + + if ( chooser.showOpenDialog( parent ) == JFileChooser.APPROVE_OPTION ) + textField.setText( chooser.getSelectedFile().getAbsolutePath() ); + } + + private void autoDetect( final JTextField execField, final JTextArea statusArea ) + { + statusArea.setForeground( new Color( 60, 120, 180 ) ); + statusArea.setText( "Auto-detecting pixi installation..." ); + + new Thread( () -> { + try + { + final PixiInfo info = PixiDetector.detect(); + SwingUtilities.invokeLater( () -> { + execField.setText( info.getPixiExecutable() ); + statusArea.setForeground( new Color( 0, 128, 0 ) ); + statusArea.setText( String.format( + "Auto-detection successful! Found pixi %s at: %s", + info.getVersion(), + info.getPixiExecutable() ) ); + } ); + } + catch ( final PixiNotFoundException e ) + { + SwingUtilities.invokeLater( () -> { + statusArea.setForeground( new Color( 180, 0, 0 ) ); + statusArea.setText( "Auto-detection failed: " + e.getMessage() ); + } ); + } + }, "Pixi-AutoDetect" ).start(); + } + + private void diagnose() + { + new Thread( () -> { + IJ.log( "\n========== Pixi Diagnostics ==========\n" ); + PixiDetector.diagnose(); + }, "Pixi-Diagnose" ).start(); + } + + private void test( final String execPath, final JTextArea statusArea ) + { + statusArea.setForeground( new Color( 60, 120, 180 ) ); + statusArea.setText( "Testing pixi executable..." ); + + new Thread( () -> { + try + { + final ProcessBuilder pb = new ProcessBuilder( execPath, "--version" ); + pb.redirectErrorStream( true ); + final Process process = pb.start(); + final StringBuilder sb = new StringBuilder(); + try ( final java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader( process.getInputStream() ) ) ) + { + String line; + while ( ( line = reader.readLine() ) != null ) + sb.append( line ); + } + final int exit = process.waitFor(); + SwingUtilities.invokeLater( () -> { + if ( exit == 0 ) + { + statusArea.setForeground( new Color( 0, 128, 0 ) ); + statusArea.setText( "Test successful: " + sb.toString().trim() ); + } + else + { + statusArea.setForeground( new Color( 180, 0, 0 ) ); + statusArea.setText( "Test failed (exit " + exit + "): " + sb.toString().trim() ); + } + } ); + } + catch ( final Exception e ) + { + SwingUtilities.invokeLater( () -> { + statusArea.setForeground( new Color( 180, 0, 0 ) ); + statusArea.setText( "Test failed: " + e.getMessage() ); + } ); + } + }, "Pixi-Test" ).start(); + } + + private void saveAndClose( final String execPath, final String projectsRoot, final PrefService prefs, final JDialog dialog ) + { + prefs.put( CLIUtils.class, CLIUtils.PIXI_PATH_PREF_KEY, execPath ); + prefs.put( CLIUtils.class, CLIUtils.PIXI_PROJECTS_ROOT_KEY, projectsRoot ); + PixiDetector.clearCache(); + + IJ.log( "Pixi configuration saved: executable = " + execPath ); + IJ.log( "Pixi projects root = " + ( projectsRoot.isEmpty() ? "(not set)" : projectsRoot ) ); + + dialog.dispose(); + } + + public static void main( final String[] args ) + { + ImageJ.main( args ); + TMUtils.getContext().getService( CommandService.class ).run( PixiPathConfigCommand.class, false ); + } +} From a0c1066471537b4ade3c5eca89333265c01252e0 Mon Sep 17 00:00:00 2001 From: Laurent Guerard Date: Thu, 18 Jun 2026 13:01:50 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(gui):=20=E2=9C=A8=20Add=20pixi=20proje?= =?UTF-8?q?ct/env=20selector=20to=20detector=20configuration=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigGuiBuilder.visit(PixiEnvironmentCommand) now builds a full pixi launcher UI inside the detector configuration panel. When the pixi projects root contains multiple projects a combobox lets the user pick the project; a second combobox shows the available environments discovered by scanning .pixi/envs/ inside that project. When only one project exists a path text field with Browse/Find/Refresh buttons is shown instead. In dual-mode panels (conda + pixi) both launchers appear in a row of labeled radio buttons separated from the conda section by a titled separator, keeping the panel compact and scrollable. State management: - pathElement.onSet hook re-syncs the project combobox and env list whenever panel.refresh() is called (e.g. from setSettings()), so the saved project path and environment are correctly restored after wizard navigation. - refreshEnvCombo() preserves the currently-selected environment when reloading the env list; falls back to the first env only when the saved value is absent or no longer valid for the project. - PixiEnvironmentCommand.set() no longer validates the value against the cached env list, which may be empty or stale at deserialization time. refreshEnvCombo() is responsible for correcting invalid values after the env list is reloaded from the file system. Co-Authored-By: Claude Sonnet 4.6 --- .../trackmate/util/cli/ConfigGuiBuilder.java | 434 +++++++++++++++++- 1 file changed, 417 insertions(+), 17 deletions(-) diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java b/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java index 1a7d30b72..53bc8243d 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java @@ -8,12 +8,12 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . @@ -44,10 +44,12 @@ import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.function.DoubleSupplier; import java.util.function.Function; @@ -62,12 +64,17 @@ import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; +import javax.swing.JFileChooser; import javax.swing.JComponent; import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JRadioButton; +import javax.swing.JScrollPane; import javax.swing.JSeparator; import javax.swing.JTextField; +import javax.swing.ListSelectionModel; import javax.swing.SwingConstants; import fiji.plugin.trackmate.gui.Fonts; @@ -83,7 +90,8 @@ import fiji.plugin.trackmate.util.FileChooser; import fiji.plugin.trackmate.util.FileChooser.DialogType; import fiji.plugin.trackmate.util.cli.CommandCLIConfigurator.ExecutablePath; -import fiji.plugin.trackmate.util.cli.CondaCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.PixiEnvironmentCommand; import fiji.plugin.trackmate.util.cli.Configurator.Argument; import fiji.plugin.trackmate.util.cli.Configurator.ArgumentVisitor; import fiji.plugin.trackmate.util.cli.Configurator.ChoiceArgument; @@ -403,16 +411,25 @@ public void visit( final CondaEnvironmentCommand arg ) { if ( arg.getEnvironments().isEmpty() ) { - // No environment found. Tell the user. - final JLabel lbl = new JLabel( "There was an error retrieving the " - + "list of conda environments. " - + "

" - + "Did you configure Conda for TrackMate? " - + "

" - + "(Edit > Options > Configure TrackMate Conda path...)" ); - lbl.setFont( Fonts.SMALL_FONT ); - lbl.setForeground( Color.RED ); - lbl.setPreferredSize( new Dimension( 200, 40 ) ); + // Compact label in dual-mode (pixi available); full error in conda-only mode. + final boolean dualMode = panel.rdbtn != null; + final JLabel lbl; + if ( dualMode ) + { + lbl = new JLabel( "Conda: not configured" ); + lbl.setFont( Fonts.SMALL_FONT ); + lbl.setForeground( Color.GRAY ); + lbl.setToolTipText( "Configure conda via Edit > Options > Configure TrackMate Conda path..." ); + } + else + { + lbl = new JLabel( "There was an error retrieving the list of conda environments." + + "

Did you configure Conda for TrackMate?" + + "

(Edit > Options > Configure TrackMate Conda path...)" ); + lbl.setFont( Fonts.SMALL_FONT ); + lbl.setForeground( Color.RED ); + lbl.setPreferredSize( new Dimension( 200, 40 ) ); + } addToLayout( arg.getHelp(), lbl ); return; } @@ -433,7 +450,354 @@ public void visit( final CondaEnvironmentCommand arg ) arg.getHelp(), new JLabel( element.getLabel() ), comboBox, - null ); + arg ); + } + + @Override + public void visit( final PixiEnvironmentCommand arg ) + { + final Configurator.PathArgument projectArg = arg.getProjectPathArg(); + + // Register path arg in panel elements so panel.refresh() syncs the field. + final StringElement pathElement = stringElement( + "Pixi project folder", projectArg::getValue, projectArg::set ); + panel.elements.put( projectArg.getKey(), pathElement ); + + final JRadioButton pixiRdbtn = panel.rdbtn; + + // Scan pixi projects from the configured root. + final String root = CLIUtils.getPixiProjectsRoot(); + final java.util.List< File > foundProjects = root.isEmpty() + ? new java.util.ArrayList<>() + : CLIUtils.findPixiProjectsInRoot( root ); + + if ( foundProjects.size() > 1 ) + { + // Multiple projects available: show comboboxes for project and env. + final String[] projectPaths = foundProjects.stream() + .map( File::getAbsolutePath ).toArray( String[]::new ); + final String[] projectNames = foundProjects.stream() + .map( File::getName ).toArray( String[]::new ); + + final JComboBox< String > projectCombo = new JComboBox<>( projectNames ); + projectCombo.setFont( Fonts.SMALL_FONT ); + projectCombo.setToolTipText( "Pixi project folder (contains pixi.toml)" ); + + // Pre-select the currently configured project, or the first one. + final String currentPath = projectArg.getValue(); + int initialIdx = 0; + if ( currentPath != null && !currentPath.isEmpty() ) + { + for ( int i = 0; i < projectPaths.length; i++ ) + { + if ( projectPaths[ i ].equals( currentPath ) ) + { + initialIdx = i; + break; + } + } + } + projectCombo.setSelectedIndex( initialIdx ); + projectArg.set( projectPaths[ initialIdx ] ); + + // Build env combobox. + arg.refreshEnvs(); + if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) + arg.set( arg.getEnvironments().get( 0 ) ); + + final ListElement< String > envElement = listElement( + arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); + panel.elements.put( arg.getKey(), envElement ); + final JComboBox< String > envCombo = linkedComboBoxSelector( envElement ); + if ( arg.isSet() ) + envCombo.setSelectedItem( arg.getValue() ); + envCombo.setFont( Fonts.SMALL_FONT ); + + // Refresh envs when project selection changes. + projectCombo.addItemListener( e -> { + if ( e.getStateChange() == ItemEvent.SELECTED ) + { + final int idx = projectCombo.getSelectedIndex(); + projectArg.set( projectPaths[ idx ] ); + refreshEnvCombo( arg, envCombo ); + } + } ); + + // Sync combobox when panel.refresh() is called (e.g. from setSettings): + // fromTrackMateSettings sets projectArg directly; without this hook + // the combobox stays on its initial selection while the model holds "". + pathElement.onSet( path -> { + int idx = 0; + if ( path != null ) + { + for ( int i = 0; i < projectPaths.length; i++ ) + { + if ( projectPaths[ i ].equals( path ) ) + { + idx = i; + break; + } + } + } + if ( projectCombo.getSelectedIndex() != idx ) + { + // Selection changes → ItemListener fires → projectArg.set + refreshEnvCombo. + projectCombo.setSelectedIndex( idx ); + } + else + { + // Combobox already at correct index but envs may be from + // a different project (built at construction time); always + // refresh so the env list and selection are consistent. + projectArg.set( projectPaths[ idx ] ); + refreshEnvCombo( arg, envCombo ); + } + } ); + + // Wire radio button. + if ( pixiRdbtn != null ) + { + pixiRdbtn.addItemListener( e -> { + final boolean sel = pixiRdbtn.isSelected(); + projectCombo.setEnabled( sel ); + envCombo.setEnabled( sel ); + } ); + projectCombo.setEnabled( pixiRdbtn.isSelected() ); + envCombo.setEnabled( pixiRdbtn.isSelected() ); + } + + // Layout: project row. + final JPanel projectHeader = new JPanel(); + projectHeader.setLayout( new BoxLayout( projectHeader, BoxLayout.LINE_AXIS ) ); + if ( pixiRdbtn != null ) + projectHeader.add( pixiRdbtn ); + final JLabel projectLabel = new JLabel( "Pixi project " ); + projectLabel.setFont( Fonts.SMALL_FONT ); + projectLabel.setToolTipText( "Select pixi project from the configured pixi projects root" ); + projectHeader.add( projectLabel ); + projectHeader.add( Box.createHorizontalGlue() ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + panel.add( projectHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + panel.add( projectCombo, c ); + c.gridy++; + + // Layout: env row. + final JPanel envHeader = new JPanel(); + envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); + final JLabel envLabel = new JLabel( arg.getName() + " " ); + envLabel.setFont( Fonts.SMALL_FONT ); + if ( arg.getHelp() != null ) + { + envLabel.setToolTipText( arg.getHelp() ); + envCombo.setToolTipText( arg.getHelp() ); + } + envHeader.add( envLabel ); + envHeader.add( Box.createHorizontalGlue() ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + panel.add( envHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + panel.add( envCombo, c ); + c.gridy++; + } + else + { + // Zero or one project: use the text-field based UI. + final JTextField pathField = linkedTextField( pathElement ); + pathField.setColumns( 10 ); + pathField.setFont( Fonts.SMALL_FONT ); + + // Auto-populate when exactly one project exists and path is not yet set. + if ( ( projectArg.getValue() == null || projectArg.getValue().isEmpty() ) + && foundProjects.size() == 1 ) + { + final String path = foundProjects.get( 0 ).getAbsolutePath(); + projectArg.set( path ); + pathField.setText( path ); + } + + // Refresh list of environments now that the project path is set. + arg.refreshEnvs(); + if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) + arg.set( arg.getEnvironments().get( 0 ) ); + + final ListElement< String > envElement = listElement( + arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); + panel.elements.put( arg.getKey(), envElement ); + final JComboBox< String > comboBox = linkedComboBoxSelector( envElement ); + if ( arg.isSet() ) + comboBox.setSelectedItem( arg.getValue() ); + comboBox.setFont( Fonts.SMALL_FONT ); + + final JButton browseButton = new JButton( "browse" ); + browseButton.setFont( Fonts.SMALL_FONT ); + browseButton.addActionListener( e -> { + final JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode( JFileChooser.DIRECTORIES_ONLY ); + chooser.setDialogTitle( "Select pixi project folder (containing pixi.toml)" ); + final String current = pathField.getText(); + if ( !current.isEmpty() ) + chooser.setCurrentDirectory( new File( current ) ); + if ( chooser.showOpenDialog( panel ) == JFileChooser.APPROVE_OPTION ) + { + pathField.setText( chooser.getSelectedFile().getAbsolutePath() ); + pathField.postActionEvent(); + } + } ); + + final JButton findButton = new JButton( "find" ); + findButton.setFont( Fonts.SMALL_FONT ); + findButton.setToolTipText( "Search the configured Pixi projects root and select a project." ); + findButton.addActionListener( e -> { + final String r = CLIUtils.getPixiProjectsRoot(); + if ( r.isEmpty() ) + { + JOptionPane.showMessageDialog( panel, + "No pixi projects root configured.\n" + + "Set it in Edit > Options > Configure TrackMate Pixi path...", + "Pixi projects root not set", JOptionPane.WARNING_MESSAGE ); + return; + } + final java.util.List< File > found = CLIUtils.findPixiProjectsInRoot( r ); + if ( found.isEmpty() ) + { + JOptionPane.showMessageDialog( panel, + "No pixi projects (subdirectories with pixi.toml) found in:\n" + r, + "No projects found", JOptionPane.INFORMATION_MESSAGE ); + return; + } + final String picked = pickPixiProject( found ); + if ( picked != null ) + { + pathField.setText( picked ); + pathField.postActionEvent(); + } + } ); + + // Refresh envs when path changes (Enter key or postActionEvent from browse/find). + pathField.addActionListener( e -> refreshEnvCombo( arg, comboBox ) ); + + final JButton refreshButton = new JButton( "refresh" ); + refreshButton.setFont( Fonts.SMALL_FONT ); + refreshButton.addActionListener( e -> { + projectArg.set( pathField.getText().trim() ); + refreshEnvCombo( arg, comboBox ); + } ); + + // Wire radio button enable/disable for all pixi components (dual-mode only). + if ( pixiRdbtn != null ) + { + pixiRdbtn.addItemListener( e -> { + final boolean sel = pixiRdbtn.isSelected(); + pathField.setEnabled( sel ); + comboBox.setEnabled( sel ); + findButton.setEnabled( sel ); + browseButton.setEnabled( sel ); + refreshButton.setEnabled( sel ); + } ); + final boolean sel = pixiRdbtn.isSelected(); + pathField.setEnabled( sel ); + comboBox.setEnabled( sel ); + findButton.setEnabled( sel ); + browseButton.setEnabled( sel ); + refreshButton.setEnabled( sel ); + } + + // Layout: project directory row. + final JPanel pathHeader = new JPanel(); + pathHeader.setLayout( new BoxLayout( pathHeader, BoxLayout.LINE_AXIS ) ); + if ( pixiRdbtn != null ) + pathHeader.add( pixiRdbtn ); + final JLabel pathLabel = new JLabel( "Pixi project folder " ); + pathLabel.setFont( Fonts.SMALL_FONT ); + pathLabel.setToolTipText( "Folder containing the pixi.toml file" ); + pathHeader.add( pathLabel ); + pathHeader.add( Box.createHorizontalGlue() ); + pathHeader.add( findButton ); + pathHeader.add( Box.createHorizontalStrut( 4 ) ); + pathHeader.add( browseButton ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + panel.add( pathHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + panel.add( pathField, c ); + c.gridy++; + + // Layout: environment row. + final JPanel envHeader = new JPanel(); + envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); + final JLabel envLabel = new JLabel( arg.getName() + " " ); + envLabel.setFont( Fonts.SMALL_FONT ); + if ( arg.getHelp() != null ) + { + envLabel.setToolTipText( arg.getHelp() ); + comboBox.setToolTipText( arg.getHelp() ); + } + envHeader.add( envLabel ); + envHeader.add( Box.createHorizontalGlue() ); + envHeader.add( refreshButton ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + panel.add( envHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + panel.add( comboBox, c ); + c.gridy++; + } + } + + private String pickPixiProject( final java.util.List< File > projects ) + { + final String[] paths = projects.stream().map( File::getAbsolutePath ).toArray( String[]::new ); + final JList< String > list = new JList<>( paths ); + list.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); + list.setSelectedIndex( 0 ); + final JScrollPane scroll = new JScrollPane( list ); + scroll.setPreferredSize( new Dimension( 500, 200 ) ); + final int result = JOptionPane.showConfirmDialog( + panel, scroll, + "Select pixi project", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE ); + if ( result == JOptionPane.OK_OPTION ) + return list.getSelectedValue(); + return null; + } + + private void refreshEnvCombo( final PixiEnvironmentCommand arg, final JComboBox< String > comboBox ) + { + final String currentEnv = arg.isSet() ? arg.getValue() : null; + arg.refreshEnvs(); + comboBox.removeAllItems(); + arg.getEnvironments().forEach( comboBox::addItem ); + if ( !arg.getEnvironments().isEmpty() ) + { + if ( currentEnv != null && !currentEnv.isEmpty() && arg.getEnvironments().contains( currentEnv ) ) + { + comboBox.setSelectedItem( currentEnv ); + arg.set( currentEnv ); + } + else + { + comboBox.setSelectedIndex( 0 ); + arg.set( ( String ) comboBox.getSelectedItem() ); + } + } } /* @@ -671,9 +1035,13 @@ private void addLastRow() public static ConfigPanel build( final Configurator config ) { final ConfigGuiBuilder builder = createBuilder( config ); - // Could we make something more elegant than this? + // Skip if the command arg is already in the arguments list (dual-mode env configurators). if ( config instanceof CLIConfigurator ) - ( ( CLIConfigurator ) config ).getCommandArg().accept( builder ); + { + final Argument< ?, ? > cmdArg = ( ( CLIConfigurator ) config ).getCommandArg(); + if ( !config.getArguments().contains( cmdArg ) ) + cmdArg.accept( builder ); + } return build( config, builder ); } @@ -711,14 +1079,46 @@ private static ConfigPanel build( final Configurator config, final ConfigGuiBuil final Argument< ?, ? > arg = it.next(); buttons.put( arg, btn ); btn.setSelected( selectable.getSelection().equals( arg ) ); + // Label launcher radio buttons so the choice is legible. + if ( EnvCLIConfigurator.KEY_LAUNCHER.equals( selectable.getKey() ) ) + { + btn.setText( arg.getName() ); + btn.setFont( Fonts.SMALL_FONT ); + } } } - // Iterate over arguments, taking care of selectable group. + // Render the launcher selector (conda vs pixi) at the top, regardless of + // where those args ended up in the argument list after subclass reordering. + final Set< Argument< ?, ? > > launcherRendered = new HashSet<>(); + for ( final SelectableArguments selectable : config.getSelectables() ) + { + if ( EnvCLIConfigurator.KEY_LAUNCHER.equals( selectable.getKey() ) ) + { + for ( final Argument< ?, ? > arg : selectable.getArguments() ) + { + if ( !arg.isVisible() ) + continue; + builder.setCurrentRadioButton( buttons.get( arg ) ); + arg.accept( builder ); + launcherRendered.add( arg ); + } + // Separator after launcher section. + builder.c.gridx = 0; + builder.c.gridwidth = 3; + builder.c.insets = new java.awt.Insets( 5, 0, 5, 0 ); + builder.panel.add( new JSeparator( JSeparator.HORIZONTAL ), builder.c ); + builder.c.gridy++; + } + } + + // Iterate over remaining arguments, skipping any already rendered above. for ( final Argument< ?, ? > arg : config.getArguments() ) { if ( !arg.isVisible() ) continue; + if ( launcherRendered.contains( arg ) ) + continue; builder.setCurrentRadioButton( buttons.get( arg ) ); arg.accept( builder ); From 3f256711fe500d7e92e20c37fdf3dad747edf265 Mon Sep 17 00:00:00 2001 From: Laurent Guerard Date: Thu, 18 Jun 2026 13:02:04 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(preview):=20=F0=9F=90=9B=20Forward=20pr?= =?UTF-8?q?eview=20result=20to=20main=20TrackMate=20logger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetectionPreview previously logged all output exclusively to a JLabelLogger — a single-line status label inside the detector configuration panel. Users looking at the TrackMate info window (opened via the info button) saw no output after clicking Preview. A new optional extraLogger field (set via Builder.extraLogger()) receives only the final summary: "Found N spots." on success or the error message on failure. GenericDetectionConfigurationPanel wires model.getLogger() as the extra logger so preview results appear in the info window. Verbose detection output (command args, progress lines) stays in the in-panel label only to avoid polluting the main log. Co-Authored-By: Claude Sonnet 4.6 --- .../trackmate/util/DetectionPreview.java | 30 +++++++++++++++++-- .../GenericDetectionConfigurationPanel.java | 3 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/fiji/plugin/trackmate/util/DetectionPreview.java b/src/main/java/fiji/plugin/trackmate/util/DetectionPreview.java index 2d6c677a8..b2fcc6f3b 100644 --- a/src/main/java/fiji/plugin/trackmate/util/DetectionPreview.java +++ b/src/main/java/fiji/plugin/trackmate/util/DetectionPreview.java @@ -50,6 +50,9 @@ public class DetectionPreview private TrackMate trackmate; + /** Receives the final summary (spots found / error) in addition to panel.logger. */ + private Logger extraLogger = Logger.VOID_LOGGER; + protected DetectionPreview( final Model model, final Settings settings, @@ -109,7 +112,9 @@ protected void preview( final Model sourceModel = out.getA(); final Double threshold = out.getB(); - panel.logger.log( "Found " + sourceModel.getSpots().getNSpots( true ) + " spots." ); + final String msg = "Found " + sourceModel.getSpots().getNSpots( true ) + " spots."; + panel.logger.log( msg ); + extraLogger.log( "[Preview] " + msg + "\n" ); // Update target model. updateModelAndHistogram( model, sourceModel, frame, threshold ); @@ -118,6 +123,7 @@ protected void preview( catch ( final Exception e ) { panel.logger.error( e.getMessage() ); + extraLogger.error( "[Preview] " + e.getMessage() + "\n" ); e.printStackTrace(); } finally @@ -198,6 +204,7 @@ protected Pair< Model, Double > runPreviewDetection( if ( !detectionOk ) { panel.logger.error( trackmate.getErrorMessage() ); + extraLogger.error( "[Preview] " + trackmate.getErrorMessage() + "\n" ); return null; } @@ -450,6 +457,23 @@ public Builder thresholdKey( final String thresholdKey ) return this; } + private Logger extraLogger = Logger.VOID_LOGGER; + + /** + * Sets a secondary logger that receives only the final preview summary + * (spots found count or error message). Useful to surface results in the + * main TrackMate log when the primary logger is an in-panel widget. + * + * @param extraLogger + * the secondary logger. + * @return this builder. + */ + public Builder extraLogger( final Logger extraLogger ) + { + this.extraLogger = ( extraLogger != null ) ? extraLogger : Logger.VOID_LOGGER; + return this; + } + public DetectionPreview get() { if ( settings == null ) @@ -463,7 +487,7 @@ public DetectionPreview get() if ( frameSupplier == null ) throw new IllegalArgumentException( "The detection frame supplier cannot be null." ); - return new DetectionPreview( + final DetectionPreview dp = new DetectionPreview( model, settings, detectorFactory, @@ -472,6 +496,8 @@ public DetectionPreview get() thresholdUpdater, axisLabel, thresholdKey ); + dp.extraLogger = extraLogger; + return dp; } } } diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/GenericDetectionConfigurationPanel.java b/src/main/java/fiji/plugin/trackmate/util/cli/GenericDetectionConfigurationPanel.java index 30e029d23..cb5b073b7 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/GenericDetectionConfigurationPanel.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/GenericDetectionConfigurationPanel.java @@ -85,7 +85,8 @@ protected DetectionPreview getDetectionPreview( .model( model ) .settings( settings ) .detectorFactory( factorySupplier.get() ) - .detectionSettingsSupplier( () -> getSettings() ); + .detectionSettingsSupplier( () -> getSettings() ) + .extraLogger( model.getLogger() ); if ( config instanceof HasInteractivePreview ) { final HasInteractivePreview hasPreview = ( HasInteractivePreview ) config; From ab0e906c2b7179a48168920bddb96f9c9b084a31 Mon Sep 17 00:00:00 2001 From: Laurent Guerard Date: Thu, 18 Jun 2026 15:18:02 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor(gui):=20=E2=99=BB=EF=B8=8F=20Extra?= =?UTF-8?q?ct=20launcher=20env=20GUI=20section=20from=20ConfigGuiBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move conda/pixi visit methods (~390 lines) into a new package-private LauncherEnvGuiSection class. ConfigGuiBuilder delegates via two-line stubs. Result: ConfigGuiBuilder 800 lines, LauncherEnvGuiSection 465 lines. Co-Authored-By: Claude Sonnet 4.6 --- .../trackmate/util/cli/ConfigGuiBuilder.java | 406 +-------------- .../util/cli/LauncherEnvGuiSection.java | 465 ++++++++++++++++++ 2 files changed, 474 insertions(+), 397 deletions(-) create mode 100644 src/main/java/fiji/plugin/trackmate/util/cli/LauncherEnvGuiSection.java diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java b/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java index 53bc8243d..bc09bc235 100644 --- a/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java +++ b/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java @@ -33,7 +33,6 @@ import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.listElement; import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.stringElement; -import java.awt.Color; import java.awt.Dimension; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; @@ -64,17 +63,12 @@ import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; -import javax.swing.JFileChooser; import javax.swing.JComponent; import javax.swing.JLabel; -import javax.swing.JList; -import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JRadioButton; -import javax.swing.JScrollPane; import javax.swing.JSeparator; import javax.swing.JTextField; -import javax.swing.ListSelectionModel; import javax.swing.SwingConstants; import fiji.plugin.trackmate.gui.Fonts; @@ -107,13 +101,13 @@ public class ConfigGuiBuilder implements ArgumentVisitor private static final int tfCols = 4; - private final ConfigPanel panel; + final ConfigPanel panel; - private final GridBagConstraints c; + final GridBagConstraints c; - private int topInset = 5; + int topInset = 5; - private int bottomInset = 5; + int bottomInset = 5; private final Map< Argument< ?, ? >, Function< ?, ? > > forwardUITranslators; @@ -409,395 +403,13 @@ public void visit( final ChoiceArgument arg ) @Override public void visit( final CondaEnvironmentCommand arg ) { - if ( arg.getEnvironments().isEmpty() ) - { - // Compact label in dual-mode (pixi available); full error in conda-only mode. - final boolean dualMode = panel.rdbtn != null; - final JLabel lbl; - if ( dualMode ) - { - lbl = new JLabel( "Conda: not configured" ); - lbl.setFont( Fonts.SMALL_FONT ); - lbl.setForeground( Color.GRAY ); - lbl.setToolTipText( "Configure conda via Edit > Options > Configure TrackMate Conda path..." ); - } - else - { - lbl = new JLabel( "There was an error retrieving the list of conda environments." - + "

Did you configure Conda for TrackMate?" - + "

(Edit > Options > Configure TrackMate Conda path...)" ); - lbl.setFont( Fonts.SMALL_FONT ); - lbl.setForeground( Color.RED ); - lbl.setPreferredSize( new Dimension( 200, 40 ) ); - } - addToLayout( arg.getHelp(), lbl ); - return; - } - - if ( !arg.isSet() ) - { - if ( !arg.hasDefaultValue() ) - throw new IllegalArgumentException( "The GUI builder requires all arguments and commands " - + "to have a value or a default value. The argument '" + arg.getName() + "' misses both." ); - arg.set( arg.getDefaultValue() ); - } - - final ListElement< String > element = listElement( arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); - panel.elements.put( arg.getKey(), element ); - final JComboBox< String > comboBox = linkedComboBoxSelector( element ); - comboBox.setSelectedItem( arg.getValue() ); - addToLayout( - arg.getHelp(), - new JLabel( element.getLabel() ), - comboBox, - arg ); + new LauncherEnvGuiSection( this ).visitConda( arg ); } @Override public void visit( final PixiEnvironmentCommand arg ) { - final Configurator.PathArgument projectArg = arg.getProjectPathArg(); - - // Register path arg in panel elements so panel.refresh() syncs the field. - final StringElement pathElement = stringElement( - "Pixi project folder", projectArg::getValue, projectArg::set ); - panel.elements.put( projectArg.getKey(), pathElement ); - - final JRadioButton pixiRdbtn = panel.rdbtn; - - // Scan pixi projects from the configured root. - final String root = CLIUtils.getPixiProjectsRoot(); - final java.util.List< File > foundProjects = root.isEmpty() - ? new java.util.ArrayList<>() - : CLIUtils.findPixiProjectsInRoot( root ); - - if ( foundProjects.size() > 1 ) - { - // Multiple projects available: show comboboxes for project and env. - final String[] projectPaths = foundProjects.stream() - .map( File::getAbsolutePath ).toArray( String[]::new ); - final String[] projectNames = foundProjects.stream() - .map( File::getName ).toArray( String[]::new ); - - final JComboBox< String > projectCombo = new JComboBox<>( projectNames ); - projectCombo.setFont( Fonts.SMALL_FONT ); - projectCombo.setToolTipText( "Pixi project folder (contains pixi.toml)" ); - - // Pre-select the currently configured project, or the first one. - final String currentPath = projectArg.getValue(); - int initialIdx = 0; - if ( currentPath != null && !currentPath.isEmpty() ) - { - for ( int i = 0; i < projectPaths.length; i++ ) - { - if ( projectPaths[ i ].equals( currentPath ) ) - { - initialIdx = i; - break; - } - } - } - projectCombo.setSelectedIndex( initialIdx ); - projectArg.set( projectPaths[ initialIdx ] ); - - // Build env combobox. - arg.refreshEnvs(); - if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) - arg.set( arg.getEnvironments().get( 0 ) ); - - final ListElement< String > envElement = listElement( - arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); - panel.elements.put( arg.getKey(), envElement ); - final JComboBox< String > envCombo = linkedComboBoxSelector( envElement ); - if ( arg.isSet() ) - envCombo.setSelectedItem( arg.getValue() ); - envCombo.setFont( Fonts.SMALL_FONT ); - - // Refresh envs when project selection changes. - projectCombo.addItemListener( e -> { - if ( e.getStateChange() == ItemEvent.SELECTED ) - { - final int idx = projectCombo.getSelectedIndex(); - projectArg.set( projectPaths[ idx ] ); - refreshEnvCombo( arg, envCombo ); - } - } ); - - // Sync combobox when panel.refresh() is called (e.g. from setSettings): - // fromTrackMateSettings sets projectArg directly; without this hook - // the combobox stays on its initial selection while the model holds "". - pathElement.onSet( path -> { - int idx = 0; - if ( path != null ) - { - for ( int i = 0; i < projectPaths.length; i++ ) - { - if ( projectPaths[ i ].equals( path ) ) - { - idx = i; - break; - } - } - } - if ( projectCombo.getSelectedIndex() != idx ) - { - // Selection changes → ItemListener fires → projectArg.set + refreshEnvCombo. - projectCombo.setSelectedIndex( idx ); - } - else - { - // Combobox already at correct index but envs may be from - // a different project (built at construction time); always - // refresh so the env list and selection are consistent. - projectArg.set( projectPaths[ idx ] ); - refreshEnvCombo( arg, envCombo ); - } - } ); - - // Wire radio button. - if ( pixiRdbtn != null ) - { - pixiRdbtn.addItemListener( e -> { - final boolean sel = pixiRdbtn.isSelected(); - projectCombo.setEnabled( sel ); - envCombo.setEnabled( sel ); - } ); - projectCombo.setEnabled( pixiRdbtn.isSelected() ); - envCombo.setEnabled( pixiRdbtn.isSelected() ); - } - - // Layout: project row. - final JPanel projectHeader = new JPanel(); - projectHeader.setLayout( new BoxLayout( projectHeader, BoxLayout.LINE_AXIS ) ); - if ( pixiRdbtn != null ) - projectHeader.add( pixiRdbtn ); - final JLabel projectLabel = new JLabel( "Pixi project " ); - projectLabel.setFont( Fonts.SMALL_FONT ); - projectLabel.setToolTipText( "Select pixi project from the configured pixi projects root" ); - projectHeader.add( projectLabel ); - projectHeader.add( Box.createHorizontalGlue() ); - - c.gridx = 0; - c.insets = new Insets( topInset, 0, 0, 0 ); - c.gridwidth = 3; - panel.add( projectHeader, c ); - c.gridy++; - c.anchor = GridBagConstraints.LINE_START; - c.insets = new Insets( 0, 0, bottomInset, 0 ); - panel.add( projectCombo, c ); - c.gridy++; - - // Layout: env row. - final JPanel envHeader = new JPanel(); - envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); - final JLabel envLabel = new JLabel( arg.getName() + " " ); - envLabel.setFont( Fonts.SMALL_FONT ); - if ( arg.getHelp() != null ) - { - envLabel.setToolTipText( arg.getHelp() ); - envCombo.setToolTipText( arg.getHelp() ); - } - envHeader.add( envLabel ); - envHeader.add( Box.createHorizontalGlue() ); - - c.gridx = 0; - c.insets = new Insets( topInset, 0, 0, 0 ); - c.gridwidth = 3; - panel.add( envHeader, c ); - c.gridy++; - c.anchor = GridBagConstraints.LINE_START; - c.insets = new Insets( 0, 0, bottomInset, 0 ); - panel.add( envCombo, c ); - c.gridy++; - } - else - { - // Zero or one project: use the text-field based UI. - final JTextField pathField = linkedTextField( pathElement ); - pathField.setColumns( 10 ); - pathField.setFont( Fonts.SMALL_FONT ); - - // Auto-populate when exactly one project exists and path is not yet set. - if ( ( projectArg.getValue() == null || projectArg.getValue().isEmpty() ) - && foundProjects.size() == 1 ) - { - final String path = foundProjects.get( 0 ).getAbsolutePath(); - projectArg.set( path ); - pathField.setText( path ); - } - - // Refresh list of environments now that the project path is set. - arg.refreshEnvs(); - if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) - arg.set( arg.getEnvironments().get( 0 ) ); - - final ListElement< String > envElement = listElement( - arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); - panel.elements.put( arg.getKey(), envElement ); - final JComboBox< String > comboBox = linkedComboBoxSelector( envElement ); - if ( arg.isSet() ) - comboBox.setSelectedItem( arg.getValue() ); - comboBox.setFont( Fonts.SMALL_FONT ); - - final JButton browseButton = new JButton( "browse" ); - browseButton.setFont( Fonts.SMALL_FONT ); - browseButton.addActionListener( e -> { - final JFileChooser chooser = new JFileChooser(); - chooser.setFileSelectionMode( JFileChooser.DIRECTORIES_ONLY ); - chooser.setDialogTitle( "Select pixi project folder (containing pixi.toml)" ); - final String current = pathField.getText(); - if ( !current.isEmpty() ) - chooser.setCurrentDirectory( new File( current ) ); - if ( chooser.showOpenDialog( panel ) == JFileChooser.APPROVE_OPTION ) - { - pathField.setText( chooser.getSelectedFile().getAbsolutePath() ); - pathField.postActionEvent(); - } - } ); - - final JButton findButton = new JButton( "find" ); - findButton.setFont( Fonts.SMALL_FONT ); - findButton.setToolTipText( "Search the configured Pixi projects root and select a project." ); - findButton.addActionListener( e -> { - final String r = CLIUtils.getPixiProjectsRoot(); - if ( r.isEmpty() ) - { - JOptionPane.showMessageDialog( panel, - "No pixi projects root configured.\n" - + "Set it in Edit > Options > Configure TrackMate Pixi path...", - "Pixi projects root not set", JOptionPane.WARNING_MESSAGE ); - return; - } - final java.util.List< File > found = CLIUtils.findPixiProjectsInRoot( r ); - if ( found.isEmpty() ) - { - JOptionPane.showMessageDialog( panel, - "No pixi projects (subdirectories with pixi.toml) found in:\n" + r, - "No projects found", JOptionPane.INFORMATION_MESSAGE ); - return; - } - final String picked = pickPixiProject( found ); - if ( picked != null ) - { - pathField.setText( picked ); - pathField.postActionEvent(); - } - } ); - - // Refresh envs when path changes (Enter key or postActionEvent from browse/find). - pathField.addActionListener( e -> refreshEnvCombo( arg, comboBox ) ); - - final JButton refreshButton = new JButton( "refresh" ); - refreshButton.setFont( Fonts.SMALL_FONT ); - refreshButton.addActionListener( e -> { - projectArg.set( pathField.getText().trim() ); - refreshEnvCombo( arg, comboBox ); - } ); - - // Wire radio button enable/disable for all pixi components (dual-mode only). - if ( pixiRdbtn != null ) - { - pixiRdbtn.addItemListener( e -> { - final boolean sel = pixiRdbtn.isSelected(); - pathField.setEnabled( sel ); - comboBox.setEnabled( sel ); - findButton.setEnabled( sel ); - browseButton.setEnabled( sel ); - refreshButton.setEnabled( sel ); - } ); - final boolean sel = pixiRdbtn.isSelected(); - pathField.setEnabled( sel ); - comboBox.setEnabled( sel ); - findButton.setEnabled( sel ); - browseButton.setEnabled( sel ); - refreshButton.setEnabled( sel ); - } - - // Layout: project directory row. - final JPanel pathHeader = new JPanel(); - pathHeader.setLayout( new BoxLayout( pathHeader, BoxLayout.LINE_AXIS ) ); - if ( pixiRdbtn != null ) - pathHeader.add( pixiRdbtn ); - final JLabel pathLabel = new JLabel( "Pixi project folder " ); - pathLabel.setFont( Fonts.SMALL_FONT ); - pathLabel.setToolTipText( "Folder containing the pixi.toml file" ); - pathHeader.add( pathLabel ); - pathHeader.add( Box.createHorizontalGlue() ); - pathHeader.add( findButton ); - pathHeader.add( Box.createHorizontalStrut( 4 ) ); - pathHeader.add( browseButton ); - - c.gridx = 0; - c.insets = new Insets( topInset, 0, 0, 0 ); - c.gridwidth = 3; - panel.add( pathHeader, c ); - c.gridy++; - c.anchor = GridBagConstraints.LINE_START; - c.insets = new Insets( 0, 0, bottomInset, 0 ); - panel.add( pathField, c ); - c.gridy++; - - // Layout: environment row. - final JPanel envHeader = new JPanel(); - envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); - final JLabel envLabel = new JLabel( arg.getName() + " " ); - envLabel.setFont( Fonts.SMALL_FONT ); - if ( arg.getHelp() != null ) - { - envLabel.setToolTipText( arg.getHelp() ); - comboBox.setToolTipText( arg.getHelp() ); - } - envHeader.add( envLabel ); - envHeader.add( Box.createHorizontalGlue() ); - envHeader.add( refreshButton ); - - c.gridx = 0; - c.insets = new Insets( topInset, 0, 0, 0 ); - c.gridwidth = 3; - panel.add( envHeader, c ); - c.gridy++; - c.anchor = GridBagConstraints.LINE_START; - c.insets = new Insets( 0, 0, bottomInset, 0 ); - panel.add( comboBox, c ); - c.gridy++; - } - } - - private String pickPixiProject( final java.util.List< File > projects ) - { - final String[] paths = projects.stream().map( File::getAbsolutePath ).toArray( String[]::new ); - final JList< String > list = new JList<>( paths ); - list.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); - list.setSelectedIndex( 0 ); - final JScrollPane scroll = new JScrollPane( list ); - scroll.setPreferredSize( new Dimension( 500, 200 ) ); - final int result = JOptionPane.showConfirmDialog( - panel, scroll, - "Select pixi project", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE ); - if ( result == JOptionPane.OK_OPTION ) - return list.getSelectedValue(); - return null; - } - - private void refreshEnvCombo( final PixiEnvironmentCommand arg, final JComboBox< String > comboBox ) - { - final String currentEnv = arg.isSet() ? arg.getValue() : null; - arg.refreshEnvs(); - comboBox.removeAllItems(); - arg.getEnvironments().forEach( comboBox::addItem ); - if ( !arg.getEnvironments().isEmpty() ) - { - if ( currentEnv != null && !currentEnv.isEmpty() && arg.getEnvironments().contains( currentEnv ) ) - { - comboBox.setSelectedItem( currentEnv ); - arg.set( currentEnv ); - } - else - { - comboBox.setSelectedIndex( 0 ); - arg.set( ( String ) comboBox.getSelectedItem() ); - } - } + new LauncherEnvGuiSection( this ).visitPixi( arg ); } /* @@ -895,7 +507,7 @@ private void addPathToLayout( final String help, final JLabel lbl, final JTextFi } } - private void addToLayout( final String help, final JLabel lbl, final JComponent comp, final Argument< ?, ? > arg ) + void addToLayout( final String help, final JLabel lbl, final JComponent comp, final Argument< ?, ? > arg ) { lbl.setText( lbl.getText() + " " ); lbl.setFont( Fonts.SMALL_FONT ); @@ -993,7 +605,7 @@ private void addToLayout( final String help, final JLabel lbl, final JComponent } } - private void addToLayout( final String help, final JComponent comp ) + void addToLayout( final String help, final JComponent comp ) { final JComponent header; if ( panel.rdbtn != null ) @@ -1176,7 +788,7 @@ public class ConfigPanel extends JPanel */ final Map< String, StyleElement > elements = new LinkedHashMap<>(); - private JRadioButton rdbtn; + JRadioButton rdbtn; private static final long serialVersionUID = 1L; diff --git a/src/main/java/fiji/plugin/trackmate/util/cli/LauncherEnvGuiSection.java b/src/main/java/fiji/plugin/trackmate/util/cli/LauncherEnvGuiSection.java new file mode 100644 index 000000000..210f9c99f --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/util/cli/LauncherEnvGuiSection.java @@ -0,0 +1,465 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2026 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.util.cli; + +import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.linkedComboBoxSelector; +import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.linkedTextField; +import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.listElement; +import static fiji.plugin.trackmate.gui.displaysettings.StyleElements.stringElement; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.Insets; +import java.awt.event.ItemEvent; +import java.io.File; +import java.util.List; + +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; + +import fiji.plugin.trackmate.gui.Fonts; +import fiji.plugin.trackmate.gui.displaysettings.StyleElements.ListElement; +import fiji.plugin.trackmate.util.cli.Configurator.PathArgument; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.CondaEnvironmentCommand; +import fiji.plugin.trackmate.util.cli.EnvCLIConfigurator.PixiEnvironmentCommand; + +/** + * Builds the conda and pixi launcher sections of a {@link ConfigGuiBuilder} + * panel. Package-private; used only by {@link ConfigGuiBuilder}. + */ +class LauncherEnvGuiSection +{ + + private final ConfigGuiBuilder b; + + LauncherEnvGuiSection( final ConfigGuiBuilder builder ) + { + this.b = builder; + } + + void visitConda( final CondaEnvironmentCommand arg ) + { + if ( arg.getEnvironments().isEmpty() ) + { + // Compact label in dual-mode (pixi available); full error in conda-only mode. + final boolean dualMode = b.panel.rdbtn != null; + final JLabel lbl; + if ( dualMode ) + { + lbl = new JLabel( "Conda: not configured" ); + lbl.setFont( Fonts.SMALL_FONT ); + lbl.setForeground( Color.GRAY ); + lbl.setToolTipText( "Configure conda via Edit > Options > Configure TrackMate Conda path..." ); + } + else + { + lbl = new JLabel( "There was an error retrieving the list of conda environments." + + "

Did you configure Conda for TrackMate?" + + "

(Edit > Options > Configure TrackMate Conda path...)" ); + lbl.setFont( Fonts.SMALL_FONT ); + lbl.setForeground( Color.RED ); + lbl.setPreferredSize( new Dimension( 200, 40 ) ); + } + b.addToLayout( arg.getHelp(), lbl ); + return; + } + + if ( !arg.isSet() ) + { + if ( !arg.hasDefaultValue() ) + throw new IllegalArgumentException( "The GUI builder requires all arguments and commands " + + "to have a value or a default value. The argument '" + arg.getName() + "' misses both." ); + arg.set( arg.getDefaultValue() ); + } + + final ListElement< String > element = listElement( arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); + b.panel.elements.put( arg.getKey(), element ); + final JComboBox< String > comboBox = linkedComboBoxSelector( element ); + comboBox.setSelectedItem( arg.getValue() ); + b.addToLayout( + arg.getHelp(), + new JLabel( element.getLabel() ), + comboBox, + arg ); + } + + void visitPixi( final PixiEnvironmentCommand arg ) + { + final PathArgument projectArg = arg.getProjectPathArg(); + final GridBagConstraints c = b.c; + final int topInset = b.topInset; + final int bottomInset = b.bottomInset; + + // Register path arg in panel elements so panel.refresh() syncs the field. + final var pathElement = stringElement( + "Pixi project folder", projectArg::getValue, projectArg::set ); + b.panel.elements.put( projectArg.getKey(), pathElement ); + + final JRadioButton pixiRdbtn = b.panel.rdbtn; + + // Scan pixi projects from the configured root. + final String root = CLIUtils.getPixiProjectsRoot(); + final java.util.List< File > foundProjects = root.isEmpty() + ? new java.util.ArrayList<>() + : CLIUtils.findPixiProjectsInRoot( root ); + + if ( foundProjects.size() > 1 ) + { + // Multiple projects available: show comboboxes for project and env. + final String[] projectPaths = foundProjects.stream() + .map( File::getAbsolutePath ).toArray( String[]::new ); + final String[] projectNames = foundProjects.stream() + .map( File::getName ).toArray( String[]::new ); + + final JComboBox< String > projectCombo = new JComboBox<>( projectNames ); + projectCombo.setFont( Fonts.SMALL_FONT ); + projectCombo.setToolTipText( "Pixi project folder (contains pixi.toml)" ); + + // Pre-select the currently configured project, or the first one. + final String currentPath = projectArg.getValue(); + int initialIdx = 0; + if ( currentPath != null && !currentPath.isEmpty() ) + { + for ( int i = 0; i < projectPaths.length; i++ ) + { + if ( projectPaths[ i ].equals( currentPath ) ) + { + initialIdx = i; + break; + } + } + } + projectCombo.setSelectedIndex( initialIdx ); + projectArg.set( projectPaths[ initialIdx ] ); + + // Build env combobox. + arg.refreshEnvs(); + if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) + arg.set( arg.getEnvironments().get( 0 ) ); + + final ListElement< String > envElement = listElement( + arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); + b.panel.elements.put( arg.getKey(), envElement ); + final JComboBox< String > envCombo = linkedComboBoxSelector( envElement ); + if ( arg.isSet() ) + envCombo.setSelectedItem( arg.getValue() ); + envCombo.setFont( Fonts.SMALL_FONT ); + + // Refresh envs when project selection changes. + projectCombo.addItemListener( e -> { + if ( e.getStateChange() == ItemEvent.SELECTED ) + { + final int idx = projectCombo.getSelectedIndex(); + projectArg.set( projectPaths[ idx ] ); + refreshEnvCombo( arg, envCombo ); + } + } ); + + // Sync combobox when panel.refresh() is called (e.g. from setSettings): + // fromTrackMateSettings sets projectArg directly; without this hook + // the combobox stays on its initial selection while the model holds "". + pathElement.onSet( path -> { + int idx = 0; + if ( path != null ) + { + for ( int i = 0; i < projectPaths.length; i++ ) + { + if ( projectPaths[ i ].equals( path ) ) + { + idx = i; + break; + } + } + } + if ( projectCombo.getSelectedIndex() != idx ) + { + // Selection changes → ItemListener fires → projectArg.set + refreshEnvCombo. + projectCombo.setSelectedIndex( idx ); + } + else + { + // Combobox already at correct index but envs may be from + // a different project (built at construction time); always + // refresh so the env list and selection are consistent. + projectArg.set( projectPaths[ idx ] ); + refreshEnvCombo( arg, envCombo ); + } + } ); + + // Wire radio button. + if ( pixiRdbtn != null ) + { + pixiRdbtn.addItemListener( e -> { + final boolean sel = pixiRdbtn.isSelected(); + projectCombo.setEnabled( sel ); + envCombo.setEnabled( sel ); + } ); + projectCombo.setEnabled( pixiRdbtn.isSelected() ); + envCombo.setEnabled( pixiRdbtn.isSelected() ); + } + + // Layout: project row. + final JPanel projectHeader = new JPanel(); + projectHeader.setLayout( new BoxLayout( projectHeader, BoxLayout.LINE_AXIS ) ); + if ( pixiRdbtn != null ) + projectHeader.add( pixiRdbtn ); + final JLabel projectLabel = new JLabel( "Pixi project " ); + projectLabel.setFont( Fonts.SMALL_FONT ); + projectLabel.setToolTipText( "Select pixi project from the configured pixi projects root" ); + projectHeader.add( projectLabel ); + projectHeader.add( Box.createHorizontalGlue() ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + b.panel.add( projectHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + b.panel.add( projectCombo, c ); + c.gridy++; + + // Layout: env row. + final JPanel envHeader = new JPanel(); + envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); + final JLabel envLabel = new JLabel( arg.getName() + " " ); + envLabel.setFont( Fonts.SMALL_FONT ); + if ( arg.getHelp() != null ) + { + envLabel.setToolTipText( arg.getHelp() ); + envCombo.setToolTipText( arg.getHelp() ); + } + envHeader.add( envLabel ); + envHeader.add( Box.createHorizontalGlue() ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + b.panel.add( envHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + b.panel.add( envCombo, c ); + c.gridy++; + } + else + { + // Zero or one project: use the text-field based UI. + final JTextField pathField = linkedTextField( pathElement ); + pathField.setColumns( 10 ); + pathField.setFont( Fonts.SMALL_FONT ); + + // Auto-populate when exactly one project exists and path is not yet set. + if ( ( projectArg.getValue() == null || projectArg.getValue().isEmpty() ) + && foundProjects.size() == 1 ) + { + final String path = foundProjects.get( 0 ).getAbsolutePath(); + projectArg.set( path ); + pathField.setText( path ); + } + + // Refresh list of environments now that the project path is set. + arg.refreshEnvs(); + if ( !arg.isSet() && !arg.getEnvironments().isEmpty() ) + arg.set( arg.getEnvironments().get( 0 ) ); + + final ListElement< String > envElement = listElement( + arg.getName(), arg.getEnvironments(), arg::getValue, arg::set ); + b.panel.elements.put( arg.getKey(), envElement ); + final JComboBox< String > comboBox = linkedComboBoxSelector( envElement ); + if ( arg.isSet() ) + comboBox.setSelectedItem( arg.getValue() ); + comboBox.setFont( Fonts.SMALL_FONT ); + + final JButton browseButton = new JButton( "browse" ); + browseButton.setFont( Fonts.SMALL_FONT ); + browseButton.addActionListener( e -> { + final JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode( JFileChooser.DIRECTORIES_ONLY ); + chooser.setDialogTitle( "Select pixi project folder (containing pixi.toml)" ); + final String current = pathField.getText(); + if ( !current.isEmpty() ) + chooser.setCurrentDirectory( new File( current ) ); + if ( chooser.showOpenDialog( b.panel ) == JFileChooser.APPROVE_OPTION ) + { + pathField.setText( chooser.getSelectedFile().getAbsolutePath() ); + pathField.postActionEvent(); + } + } ); + + final JButton findButton = new JButton( "find" ); + findButton.setFont( Fonts.SMALL_FONT ); + findButton.setToolTipText( "Search the configured Pixi projects root and select a project." ); + findButton.addActionListener( e -> { + final String r = CLIUtils.getPixiProjectsRoot(); + if ( r.isEmpty() ) + { + JOptionPane.showMessageDialog( b.panel, + "No pixi projects root configured.\n" + + "Set it in Edit > Options > Configure TrackMate Pixi path...", + "Pixi projects root not set", JOptionPane.WARNING_MESSAGE ); + return; + } + final java.util.List< File > found = CLIUtils.findPixiProjectsInRoot( r ); + if ( found.isEmpty() ) + { + JOptionPane.showMessageDialog( b.panel, + "No pixi projects (subdirectories with pixi.toml) found in:\n" + r, + "No projects found", JOptionPane.INFORMATION_MESSAGE ); + return; + } + final String picked = pickPixiProject( found ); + if ( picked != null ) + { + pathField.setText( picked ); + pathField.postActionEvent(); + } + } ); + + // Refresh envs when path changes (Enter key or postActionEvent from browse/find). + pathField.addActionListener( e -> refreshEnvCombo( arg, comboBox ) ); + + final JButton refreshButton = new JButton( "refresh" ); + refreshButton.setFont( Fonts.SMALL_FONT ); + refreshButton.addActionListener( e -> { + projectArg.set( pathField.getText().trim() ); + refreshEnvCombo( arg, comboBox ); + } ); + + // Wire radio button enable/disable for all pixi components (dual-mode only). + if ( pixiRdbtn != null ) + { + pixiRdbtn.addItemListener( e -> { + final boolean sel = pixiRdbtn.isSelected(); + pathField.setEnabled( sel ); + comboBox.setEnabled( sel ); + findButton.setEnabled( sel ); + browseButton.setEnabled( sel ); + refreshButton.setEnabled( sel ); + } ); + final boolean sel = pixiRdbtn.isSelected(); + pathField.setEnabled( sel ); + comboBox.setEnabled( sel ); + findButton.setEnabled( sel ); + browseButton.setEnabled( sel ); + refreshButton.setEnabled( sel ); + } + + // Layout: project directory row. + final JPanel pathHeader = new JPanel(); + pathHeader.setLayout( new BoxLayout( pathHeader, BoxLayout.LINE_AXIS ) ); + if ( pixiRdbtn != null ) + pathHeader.add( pixiRdbtn ); + final JLabel pathLabel = new JLabel( "Pixi project folder " ); + pathLabel.setFont( Fonts.SMALL_FONT ); + pathLabel.setToolTipText( "Folder containing the pixi.toml file" ); + pathHeader.add( pathLabel ); + pathHeader.add( Box.createHorizontalGlue() ); + pathHeader.add( findButton ); + pathHeader.add( Box.createHorizontalStrut( 4 ) ); + pathHeader.add( browseButton ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + b.panel.add( pathHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + b.panel.add( pathField, c ); + c.gridy++; + + // Layout: environment row. + final JPanel envHeader = new JPanel(); + envHeader.setLayout( new BoxLayout( envHeader, BoxLayout.LINE_AXIS ) ); + final JLabel envLabel = new JLabel( arg.getName() + " " ); + envLabel.setFont( Fonts.SMALL_FONT ); + if ( arg.getHelp() != null ) + { + envLabel.setToolTipText( arg.getHelp() ); + comboBox.setToolTipText( arg.getHelp() ); + } + envHeader.add( envLabel ); + envHeader.add( Box.createHorizontalGlue() ); + envHeader.add( refreshButton ); + + c.gridx = 0; + c.insets = new Insets( topInset, 0, 0, 0 ); + c.gridwidth = 3; + b.panel.add( envHeader, c ); + c.gridy++; + c.anchor = GridBagConstraints.LINE_START; + c.insets = new Insets( 0, 0, bottomInset, 0 ); + b.panel.add( comboBox, c ); + c.gridy++; + } + } + + private String pickPixiProject( final List< File > projects ) + { + final String[] paths = projects.stream().map( File::getAbsolutePath ).toArray( String[]::new ); + final JList< String > list = new JList<>( paths ); + list.setSelectionMode( ListSelectionModel.SINGLE_SELECTION ); + list.setSelectedIndex( 0 ); + final JScrollPane scroll = new JScrollPane( list ); + scroll.setPreferredSize( new Dimension( 500, 200 ) ); + final int result = JOptionPane.showConfirmDialog( + b.panel, scroll, + "Select pixi project", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE ); + if ( result == JOptionPane.OK_OPTION ) + return list.getSelectedValue(); + return null; + } + + private void refreshEnvCombo( final PixiEnvironmentCommand arg, final JComboBox< String > comboBox ) + { + final String currentEnv = arg.isSet() ? arg.getValue() : null; + arg.refreshEnvs(); + comboBox.removeAllItems(); + arg.getEnvironments().forEach( comboBox::addItem ); + if ( !arg.getEnvironments().isEmpty() ) + { + if ( currentEnv != null && !currentEnv.isEmpty() && arg.getEnvironments().contains( currentEnv ) ) + { + comboBox.setSelectedItem( currentEnv ); + arg.set( currentEnv ); + } + else + { + comboBox.setSelectedIndex( 0 ); + arg.set( ( String ) comboBox.getSelectedItem() ); + } + } + } +}