From a30105c9ae7b91177e972461b8812f284f529d85 Mon Sep 17 00:00:00 2001 From: Karl Tauber Date: Tue, 13 Jan 2026 17:22:50 +0100 Subject: [PATCH] System File Chooser: added `PatternFilter` to support glob file filter on Windows and on Linux, but not on macOS (issue #1076) --- CHANGELOG.md | 2 + .../flatlaf/ui/FlatNativeMacLibrary.java | 2 +- .../flatlaf/util/SystemFileChooser.java | 182 +++++++++++++++++- .../testing/FlatSystemFileChooserTest.java | 4 +- 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e505b8fe..b06f4af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ FlatLaf Change Log - System File Chooser: - Update current filter before invoking approve callback and after closing dialog. (issue #1065) + - Added `PatternFilter` to support glob file filter (e.g. `*.tar.gz`) on + Windows and on Linux, but not on macOS. (issue #1076) - Fixed: System and Swing file dialogs were shown at the same time if application has no other displayable window. (issue #1078) - On Linux: Check whether required GSettings schemas are installed to avoid diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java index 8799ebc6..d4b1aae5 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/ui/FlatNativeMacLibrary.java @@ -115,7 +115,7 @@ public class FlatNativeMacLibrary * @param fileTypes file types that the dialog can open or save. * Two or more strings and {@code null} are required for each filter. * First string is the display name of the filter shown in the combobox (e.g. "Text Files"). - * Subsequent strings are the filter patterns (e.g. "txt" or "*"). + * Subsequent strings are the file name extensions (e.g. "txt" or "*"; '.' is not supported). * {@code null} is required to mark end of filter. * @param retFileTypeIndex returns selected file type (zero-based); array must be have one element * @return file path(s) that the user selected; an empty array if canceled; diff --git a/flatlaf-core/src/main/java/com/formdev/flatlaf/util/SystemFileChooser.java b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/SystemFileChooser.java index f05b892c..9d6b4edb 100644 --- a/flatlaf-core/src/main/java/com/formdev/flatlaf/util/SystemFileChooser.java +++ b/flatlaf-core/src/main/java/com/formdev/flatlaf/util/SystemFileChooser.java @@ -36,6 +36,7 @@ import java.util.Locale; import java.util.Map; import java.util.Scanner; import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; import javax.swing.JDialog; import javax.swing.JFileChooser; import javax.swing.JOptionPane; @@ -90,7 +91,8 @@ import com.formdev.flatlaf.ui.FlatNativeWindowsLibrary; *
  • {@link JFileChooser#FILES_AND_DIRECTORIES} is not supported. *
  • {@link #getSelectedFiles()} returns selected file also in single selection mode. * {@link JFileChooser#getSelectedFiles()} only in multi selection mode. - *
  • Only file name extension filters (see {@link FileNameExtensionFilter}) are supported. + *
  • Only file name extension filters (see {@link FileNameExtensionFilter}) are supported on all platforms. + *
  • Pattern filters (see {@link PatternFilter}) are only supported on Windows and Linux, but not on macOS. *
  • If adding choosable file filters and {@link #isAcceptAllFileFilterUsed()} is {@code true}, * then the All Files filter is placed at the end of the combobox list * (as usual in current operating systems) and the first choosable filter is selected by default. @@ -587,6 +589,7 @@ public class SystemFileChooser private void checkSupportedFileFilter( FileFilter filter ) throws IllegalArgumentException { if( filter == null || filter instanceof FileNameExtensionFilter || + filter instanceof PatternFilter || filter instanceof AcceptAllFileFilter ) return; @@ -936,6 +939,10 @@ public class SystemFileChooser fileTypes.add( filter.getDescription() ); fileTypes.add( "*." + String.join( ";*.", ((FileNameExtensionFilter)filter).getExtensions() ) ); fileTypeFilters.add( filter ); + } else if( filter instanceof PatternFilter ) { + fileTypes.add( filter.getDescription() ); + fileTypes.add( String.join( ";", ((PatternFilter)filter).getPatterns() ) ); + fileTypeFilters.add( filter ); } else if( filter instanceof AcceptAllFileFilter ) { fileTypes.add( filter.getDescription() ); fileTypes.add( "*.*" ); @@ -1013,6 +1020,26 @@ public class SystemFileChooser private static class MacFileChooserProvider extends SystemFileChooserProvider { + @Override + public File[] showDialog( Window owner, SystemFileChooser fc ) { + // fallback to Swing file chooser if PatternFilter is used + boolean usesPatternFilter = (fc.getFileFilter() instanceof PatternFilter); + if( !usesPatternFilter ) { + for( FileFilter filter : fc.getChoosableFileFilters() ) { + if( filter instanceof PatternFilter ) { + usesPatternFilter = true; + break; + } + } + } + if( usesPatternFilter ) { + LoggingFacade.INSTANCE.logSevere( "FlatLaf: SystemFileChooser.PatternFilter is not supported on macOS. Using Swing JFileChooser.", null ); + return new SwingFileChooserProvider().showDialog( owner, fc ); + } + + return super.showDialog( owner, fc ); + } + @Override String[] showSystemDialog( Window owner, SystemFileChooser fc ) { int dark = FlatLaf.isLafDark() ? 1 : 0; @@ -1202,9 +1229,14 @@ public class SystemFileChooser if( filter instanceof FileNameExtensionFilter ) { fileTypes.add( filter.getDescription() ); for( String ext : ((FileNameExtensionFilter)filter).getExtensions() ) - fileTypes.add( caseInsensitiveGlobPattern( ext ) ); + fileTypes.add( "*." + caseInsensitiveGlobPattern( ext ) ); fileTypes.add( null ); fileTypeFilters.add( filter ); + } else if( filter instanceof PatternFilter ) { + fileTypes.add( filter.getDescription() ); + for( String pattern : ((PatternFilter)filter).getPatterns() ) + fileTypes.add( caseInsensitiveGlobPattern( pattern ) ); + fileTypeFilters.add( filter ); } else if( filter instanceof AcceptAllFileFilter ) { fileTypes.add( filter.getDescription() ); fileTypes.add( "*" ); @@ -1235,7 +1267,6 @@ public class SystemFileChooser private String caseInsensitiveGlobPattern( String ext ) { StringBuilder buf = new StringBuilder(); - buf.append( "*." ); int len = ext.length(); for( int i = 0; i < len; i++ ) { char ch = ext.charAt( i ); @@ -1426,6 +1457,10 @@ public class SystemFileChooser return new javax.swing.filechooser.FileNameExtensionFilter( ((FileNameExtensionFilter)filter).getDescription(), ((FileNameExtensionFilter)filter).getExtensions() ); + } else if( filter instanceof PatternFilter ) { + return new SwingGlobFilter( + ((PatternFilter)filter).getDescription(), + ((PatternFilter)filter).getPatterns() ); } else if( filter instanceof AcceptAllFileFilter ) return chooser.getAcceptAllFileFilter(); else @@ -1515,6 +1550,86 @@ public class SystemFileChooser null, buttons, buttons[Math.min( Math.max( defaultButton, 0 ), buttons.length - 1 )] ); } } + + //---- class SwingGlobFilter ------------------------------------------ + + private static class SwingGlobFilter + extends javax.swing.filechooser.FileFilter + { + private final String description; + private final String[] patterns; + private Pattern regexPattern; + + SwingGlobFilter( String description, String... patterns ) { + this.description = description; + this.patterns = patterns; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public boolean accept( File f ) { + if( f == null ) + return false; + + if( f.isDirectory() ) + return true; + + initRegexPattern(); + return regexPattern.matcher( f.getName() ).matches(); + } + + private void initRegexPattern() { + if( regexPattern != null ) + return; + + StringBuilder buf = new StringBuilder(); + for( String pattern : patterns ) { + if( buf.length() > 0 ) + buf.append( '|' ); + glob2regexPattern( pattern, buf ); + } + regexPattern = Pattern.compile( buf.toString(), Pattern.CASE_INSENSITIVE ); + } + + private static void glob2regexPattern( String globPattern, StringBuilder buf ) { + int globLength = globPattern.length(); + + // on windows, a pattern ending with "*.*" is equal to ending with "*" + if( SystemInfo.isWindows && globPattern.endsWith( "*.*" ) ) + globLength -= 2; + + for( int i = 0; i < globLength; i++ ) { + char ch = globPattern.charAt( i ); + switch( ch ) { + // glob pattern + case '*': buf.append( ".*" ); break; + case '?': buf.append( '.' ); break; + + // escape special regex characters + case '\\': + case '.': + case '+': + case '^': + case '$': + case '(': + case ')': + case '{': + case '}': + case '[': + case ']': + case '|': + buf.append( '\\' ).append( ch ); + break; + + default: buf.append( ch ); break; + } + } + } + } } //---- class FileFilter --------------------------------------------------- @@ -1566,6 +1681,67 @@ public class SystemFileChooser } } + //---- class PatternFilter ------------------------------------------------ + + /** + * A case-insensitive file filter which accepts file patterns containing + * the wildcard characters {@code *?} on Windows and Linux. + * + * Sample filters: {@code *.tar.gz} or {@code *_copy.txt} + *

    + * Warning: This filter is not supported on macOS. + * If used on macOS, the Swing file chooser {@link JFileChooser} is shown + * (instead of macOS file dialog) and a warning is logged. + * To avoid this, do not use this filter on macOS. + *

    + * E.g.: + *

    {@code
    +	 * if( SystemInfo.isMacOS )
    +	 *     chooser.addChoosableFileFilter( new FileNameExtensionFilter( "Compressed TAR", "tgz" ) );
    +	 * else
    +	 *     chooser.addChoosableFileFilter( new PatternFilter( "Compressed TAR", "*.tar.gz" ) );
    +	 * } );
    +	 * }
    + * + * @see FileNameExtensionFilter + * @since 3.7.1 + */ + public static final class PatternFilter + extends FileFilter + { + private final String description; + private final String[] patterns; + + public PatternFilter( String description, String... patterns ) { + if( patterns == null || patterns.length == 0 ) + throw new IllegalArgumentException( "Missing patterns" ); + for( String extension : patterns ) { + if( extension == null || extension.isEmpty() ) + throw new IllegalArgumentException( "Pattern is null or empty string" ); + } + + this.description = description; + this.patterns = patterns.clone(); + } + + @Override + public String getDescription() { + return description; + } + + public String[] getPatterns() { + return patterns.clone(); + } + + @Override + public String toString() { + return super.toString() + "[description=" + description + " patterns=" + Arrays.toString( patterns ) + "]"; + } + } + //---- class AcceptAllFileFilter ------------------------------------------ private static final class AcceptAllFileFilter diff --git a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatSystemFileChooserTest.java b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatSystemFileChooserTest.java index c063e307..03751ec1 100644 --- a/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatSystemFileChooserTest.java +++ b/flatlaf-testing/src/main/java/com/formdev/flatlaf/testing/FlatSystemFileChooserTest.java @@ -234,7 +234,9 @@ public class FlatSystemFileChooserTest for( int i = 0; i < fileTypes.length; i += 2 ) { fc.addChoosableFileFilter( "*".equals( fileTypes[i+1] ) ? fc.getAcceptAllFileFilter() - : new SystemFileChooser.FileNameExtensionFilter( fileTypes[i], fileTypes[i+1].split( ";" ) ) ); + : ((fileTypes[i+1].indexOf( '*' ) >= 0 || fileTypes[i+1].indexOf( '?' ) >= 0) + ? new SystemFileChooser.PatternFilter( fileTypes[i], fileTypes[i+1].split( ";" ) ) + : new SystemFileChooser.FileNameExtensionFilter( fileTypes[i], fileTypes[i+1].split( ";" ) )) ); } SystemFileChooser.FileFilter[] filters = fc.getChoosableFileFilters(); if( filters.length > 0 )