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/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/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/ConfigGuiBuilder.java b/src/main/java/fiji/plugin/trackmate/util/cli/ConfigGuiBuilder.java index 1a7d30b72..bc09bc235 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 * . @@ -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; @@ -44,10 +43,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; @@ -83,7 +84,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; @@ -99,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; @@ -401,39 +403,13 @@ public void visit( final ChoiceArgument arg ) @Override 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 ) ); - 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() ); - } + new LauncherEnvGuiSection( this ).visitConda( arg ); + } - 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, - null ); + @Override + public void visit( final PixiEnvironmentCommand arg ) + { + new LauncherEnvGuiSection( this ).visitPixi( arg ); } /* @@ -531,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 ); @@ -629,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 ) @@ -671,9 +647,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 +691,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 ); + } + } + } + + // 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 arguments, taking care of selectable group. + // 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 ); @@ -776,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/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/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; 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() ); + } + } + } +} 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 ); + } +}