diff --git a/file.go b/file.go index d50a65b..aacf15e 100644 --- a/file.go +++ b/file.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "unicode" ) // SelectFile displays the file selection dialog. @@ -69,6 +70,8 @@ func Filename(filename string) Option { // FileFilter is an Option that sets a filename filter. // +// On Windows and macOS filtering is always case-insensitive. +// // macOS hides filename filters from the user, // and only supports filtering by extension // (or "uniform type identifiers"). @@ -78,6 +81,7 @@ func Filename(filename string) Option { type FileFilter struct { Name string // display string that describes the filter (optional) Patterns []string // filter patterns for the display string + CaseFold bool // if set patterns are matched case-insensitively } func (f FileFilter) apply(o *options) { @@ -91,7 +95,7 @@ func (f FileFilters) apply(o *options) { o.fileFilters = append(o.fileFilters, f...) } -// Windows' patterns need a name. +// Windows patterns need a name. func (f FileFilters) name() { for i, filter := range f { if filter.Name == "" { @@ -100,47 +104,42 @@ func (f FileFilters) name() { } } -// Windows' patterns are case insensitive, don't support character classes or escaping. +// Windows patterns are case-insensitive, don't support character classes or escaping. // // First we remove character classes, then escaping. Patterns with literal wildcards are invalid. // The semicolon is a separator, so we replace it with the single character wildcard. -// Empty and invalid filters/patterns are ignored. func (f FileFilters) simplify() { - var i = 0 - for _, filter := range f { + for i := range f { var j = 0 - for _, pattern := range filter.Patterns { + for _, pattern := range f[i].Patterns { var escape, invalid bool var buf strings.Builder - for _, r := range removeClasses(pattern) { - if !escape && r == '\\' { + for _, b := range []byte(removeClasses(pattern)) { + if !escape && b == '\\' { escape = true continue } - if escape && (r == '*' || r == '?') { + if escape && (b == '*' || b == '?') { invalid = true break } - if r == ';' { - r = '?' + if b == ';' { + b = '?' } - buf.WriteRune(r) + buf.WriteByte(b) escape = false } if buf.Len() > 0 && !invalid { - filter.Patterns[j] = buf.String() + f[i].Patterns[j] = buf.String() j++ } } - if j > 0 { - filter.Patterns = filter.Patterns[:j] - f[i] = filter - i++ + if j != 0 { + f[i].Patterns = f[i].Patterns[:j] + } else { + f[i].Patterns = nil } } - for ; i < len(f); i++ { - f[i] = FileFilter{} - } } // macOS types may be specified as extension strings without the leading period, @@ -163,17 +162,17 @@ func (f FileFilters) types() []string { var escape bool var buf strings.Builder - for _, r := range removeClasses(ext) { + for _, b := range []byte(removeClasses(ext)) { switch { case escape: escape = false - case r == '\\': + case b == '\\': escape = true continue - case r == '*' || r == '?': + case b == '*' || b == '?': return nil } - buf.WriteRune(r) + buf.WriteByte(b) } if buf.Len() > 0 { res = append(res, buf.String()) @@ -187,8 +186,57 @@ func (f FileFilters) types() []string { return append([]string{""}, res...) } +// Unix patterns are case-sensitive. Fold them if requested. +func (f FileFilters) casefold() { + for i := range f { + if !f[i].CaseFold { + continue + } + for j, pattern := range f[i].Patterns { + var class = -1 + var escape bool + var buf strings.Builder + for i, r := range pattern { + switch { + case escape: + escape = false + case r == '\\': + escape = true + case class < 0: + if r == '[' { + class = i + } + case class < i-1: + if r == ']' { + class = -1 + } + } + + nr := unicode.SimpleFold(r) + if r == nr { + buf.WriteRune(r) + continue + } + + if class < 0 { + buf.WriteByte('[') + } + buf.WriteRune(r) + for r != nr { + buf.WriteRune(nr) + nr = unicode.SimpleFold(nr) + } + if class < 0 { + buf.WriteByte(']') + } + } + f[i].Patterns[j] = buf.String() + } + } +} + // Remove character classes from pattern, assuming case insensitivity. -// Classes of one character (case insensitive) are replaced by the character. +// Classes of one character (case-insensitive) are replaced by the character. // Others are replaced by the single character wildcard. func removeClasses(pattern string) string { var res strings.Builder @@ -229,7 +277,7 @@ func removeClasses(pattern string) string { // Find a character class in the pattern. func findClass(pattern string) (start, end int) { start = -1 - escape := false + var escape bool for i, b := range []byte(pattern) { switch { case escape: @@ -240,7 +288,7 @@ func findClass(pattern string) (start, end int) { if b == '[' { start = i } - case 0 <= start && start < i-1: + case start < i-1: if b == ']' { return start, i + 1 } diff --git a/file_filter_test.go b/file_filter_test.go index e95b7fd..22ae48c 100644 --- a/file_filter_test.go +++ b/file_filter_test.go @@ -10,9 +10,9 @@ func TestFileFilters_name(t *testing.T) { data FileFilters want string }{ - {FileFilters{{"", []string{`*.png`}}}, "*.png"}, - {FileFilters{{"", []string{`*.png`, `*.jpg`}}}, "*.png *.jpg"}, - {FileFilters{{"Image files", []string{`*.png`, `*.jpg`}}}, "Image files"}, + {FileFilters{{"", []string{`*.png`}, true}}, "*.png"}, + {FileFilters{{"", []string{`*.png`, `*.jpg`}, true}}, "*.png *.jpg"}, + {FileFilters{{"Image files", []string{`*.png`, `*.jpg`}, true}}, "Image files"}, } for i, tt := range tests { tt.data.name() @@ -24,57 +24,88 @@ func TestFileFilters_name(t *testing.T) { func TestFileFilters_simplify(t *testing.T) { tests := []struct { - data FileFilters + data []string want []string }{ - {FileFilters{{"", []string{``}}}, nil}, - {FileFilters{{"", []string{`*.png`}}}, []string{"*.png"}}, - {FileFilters{{"", []string{`*.pn?`}}}, []string{"*.pn?"}}, - {FileFilters{{"", []string{`*.pn;`}}}, []string{"*.pn?"}}, - {FileFilters{{"", []string{`*.pn\?`}}}, nil}, - {FileFilters{{"", []string{`*.[PpNnGg]`}}}, []string{"*.?"}}, - {FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"*.PNG"}}, - {FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"*.PNG"}}, - {FileFilters{{"", []string{`*.[PNG`}}}, []string{"*.[PNG"}}, - {FileFilters{{"", []string{`*.]PNG`}}}, []string{"*.]PNG"}}, - {FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"*.[PNG"}}, - {FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"*.]PNG"}}, - {FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"*.[PNG"}}, - {FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"*.]PNG"}}, - {FileFilters{{"", []string{`public.png`}}}, []string{"public.png"}}, + {[]string{``}, nil}, + {[]string{`*.\?`}, nil}, + {[]string{`*.png`}, []string{"*.png"}}, + {[]string{`*.pn?`}, []string{"*.pn?"}}, + {[]string{`*.pn;`}, []string{"*.pn?"}}, + {[]string{`*.[PpNnGg]`}, []string{"*.?"}}, + {[]string{`*.[Pp][Nn][Gg]`}, []string{"*.PNG"}}, + {[]string{`*.[Pp][\Nn][G\g]`}, []string{"*.PNG"}}, + {[]string{`*.[PNG`}, []string{"*.[PNG"}}, + {[]string{`*.]PNG`}, []string{"*.]PNG"}}, + {[]string{`*.[[]PNG`}, []string{"*.[PNG"}}, + {[]string{`*.[]]PNG`}, []string{"*.]PNG"}}, + {[]string{`*.[\[]PNG`}, []string{"*.[PNG"}}, + {[]string{`*.[\]]PNG`}, []string{"*.]PNG"}}, + {[]string{`public.png`}, []string{"public.png"}}, } for i, tt := range tests { - tt.data.simplify() - if got := tt.data[0].Patterns; !reflect.DeepEqual(got, tt.want) { + filters := FileFilters{FileFilter{Patterns: tt.data}} + filters.simplify() + if got := filters[0].Patterns; !reflect.DeepEqual(got, tt.want) { t.Errorf("FileFilters.simplify[%d] = %q; want %q", i, got, tt.want) } } } +func TestFileFilters_casefold(t *testing.T) { + tests := []struct { + data []string + want []string + }{ + {[]string{`*.png`}, []string{`*.[pP][nN][gG]`}}, + {[]string{`*.pn?`}, []string{`*.[pP][nN]?`}}, + {[]string{`*.pn;`}, []string{`*.[pP][nN];`}}, + {[]string{`*.pn\?`}, []string{`*.[pP][nN]\?`}}, + {[]string{`*.[PpNnGg]`}, []string{`*.[PppPNnnNGggG]`}}, + {[]string{`*.[Pp][Nn][Gg]`}, []string{`*.[PppP][NnnN][GggG]`}}, + {[]string{`*.[Pp][\Nn][G\g]`}, []string{`*.[PppP][\NnnN][Gg\gG]`}}, + {[]string{`*.[PNG`}, []string{`*.[PpNnGg`}}, + {[]string{`*.]PNG`}, []string{`*.][Pp][Nn][Gg]`}}, + {[]string{`*.[[]PNG`}, []string{`*.[[][Pp][Nn][Gg]`}}, + {[]string{`*.[]]PNG`}, []string{`*.[]][Pp][Nn][Gg]`}}, + {[]string{`*.[\[]PNG`}, []string{`*.[\[][Pp][Nn][Gg]`}}, + {[]string{`*.[\]]PNG`}, []string{`*.[\]][Pp][Nn][Gg]`}}, + } + for i, tt := range tests { + filters := FileFilters{FileFilter{Patterns: tt.data}} + filters[0].CaseFold = true + filters.casefold() + if got := filters[0].Patterns; !reflect.DeepEqual(got, tt.want) { + t.Errorf("FileFilters.casefold[%d] = %q; want %q", i, got, tt.want) + } + } +} + func TestFileFilters_types(t *testing.T) { tests := []struct { - data FileFilters + data []string want []string }{ - {FileFilters{{"", []string{``}}}, nil}, - {FileFilters{{"", []string{`*.png`}}}, []string{"", "png"}}, - {FileFilters{{"", []string{`*.pn?`}}}, nil}, - {FileFilters{{"", []string{`*.pn;`}}}, []string{"", "pn;"}}, - {FileFilters{{"", []string{`*.pn\?`}}}, []string{"", "pn?"}}, - {FileFilters{{"", []string{`*.[PpNnGg]`}}}, nil}, - {FileFilters{{"", []string{`*.[Pp][Nn][Gg]`}}}, []string{"", "PNG"}}, - {FileFilters{{"", []string{`*.[Pp][\Nn][G\g]`}}}, []string{"", "PNG"}}, - {FileFilters{{"", []string{`*.[PNG`}}}, []string{"", "[PNG"}}, - {FileFilters{{"", []string{`*.]PNG`}}}, []string{"", "]PNG"}}, - {FileFilters{{"", []string{`*.[[]PNG`}}}, []string{"", "[PNG"}}, - {FileFilters{{"", []string{`*.[]]PNG`}}}, []string{"", "]PNG"}}, - {FileFilters{{"", []string{`*.[\[]PNG`}}}, []string{"", "[PNG"}}, - {FileFilters{{"", []string{`*.[\]]PNG`}}}, []string{"", "]PNG"}}, - {FileFilters{{"", []string{`public.png`}}}, []string{"", "public.png"}}, - {FileFilters{{"", []string{`-public-.png`}}}, []string{"", "png"}}, + {[]string{``}, nil}, + {[]string{`*.png`}, []string{"", "png"}}, + {[]string{`*.pn?`}, nil}, + {[]string{`*.pn;`}, []string{"", "pn;"}}, + {[]string{`*.pn\?`}, []string{"", "pn?"}}, + {[]string{`*.[PpNnGg]`}, nil}, + {[]string{`*.[Pp][Nn][Gg]`}, []string{"", "PNG"}}, + {[]string{`*.[Pp][\Nn][G\g]`}, []string{"", "PNG"}}, + {[]string{`*.[PNG`}, []string{"", "[PNG"}}, + {[]string{`*.]PNG`}, []string{"", "]PNG"}}, + {[]string{`*.[[]PNG`}, []string{"", "[PNG"}}, + {[]string{`*.[]]PNG`}, []string{"", "]PNG"}}, + {[]string{`*.[\[]PNG`}, []string{"", "[PNG"}}, + {[]string{`*.[\]]PNG`}, []string{"", "]PNG"}}, + {[]string{`public.png`}, []string{"", "public.png"}}, + {[]string{`-public-.png`}, []string{"", "png"}}, } for i, tt := range tests { - if got := tt.data.types(); !reflect.DeepEqual(got, tt.want) { + filters := FileFilters{FileFilter{Patterns: tt.data}} + if got := filters.types(); !reflect.DeepEqual(got, tt.want) { t.Errorf("FileFilters.types[%d] = %v; want %v", i, got, tt.want) } } diff --git a/file_test.go b/file_test.go index d1df66e..8b70656 100644 --- a/file_test.go +++ b/file_test.go @@ -20,9 +20,9 @@ func ExampleSelectFile() { zenity.SelectFile( zenity.Filename(defaultPath), zenity.FileFilters{ - {"Go files", []string{"*.go"}}, - {"Web files", []string{"*.html", "*.js", "*.css"}}, - {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, + {"Go files", []string{"*.go"}, true}, + {"Web files", []string{"*.html", "*.js", "*.css"}, true}, + {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true}, }) } @@ -30,9 +30,9 @@ func ExampleSelectFileMultiple() { zenity.SelectFileMultiple( zenity.Filename(defaultPath), zenity.FileFilters{ - {"Go files", []string{"*.go"}}, - {"Web files", []string{"*.html", "*.js", "*.css"}}, - {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, + {"Go files", []string{"*.go"}, true}, + {"Web files", []string{"*.html", "*.js", "*.css"}, true}, + {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true}, }) } @@ -41,9 +41,9 @@ func ExampleSelectFileSave() { zenity.ConfirmOverwrite(), zenity.Filename(defaultName), zenity.FileFilters{ - {"Go files", []string{"*.go"}}, - {"Web files", []string{"*.html", "*.js", "*.css"}}, - {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}}, + {"Go files", []string{"*.go"}, true}, + {"Web files", []string{"*.html", "*.js", "*.css"}, true}, + {"Image files", []string{"*.png", "*.gif", "*.ico", "*.jpg", "*.webp"}, true}, }) } @@ -232,7 +232,7 @@ func TestSelectFileSave_script(t *testing.T) { str, err := zenity.SelectFileSave( zenity.ConfirmOverwrite(), zenity.Filename("Χρτο.go"), - zenity.FileFilter{"Go files", []string{"*.go"}}, + zenity.FileFilter{"Go files", []string{"*.go"}, true}, ) if skip, err := skip(err); skip { t.Skip("skipping:", err) diff --git a/file_windows.go b/file_windows.go index 4dc2a2c..4fce08d 100644 --- a/file_windows.go +++ b/file_windows.go @@ -340,6 +340,9 @@ func initFilters(filters FileFilters) []uint16 { filters.name() var res []uint16 for _, f := range filters { + if f.Name == "" || len(f.Patterns) == 0 { + continue + } res = append(res, utf16.Encode([]rune(f.Name))...) res = append(res, 0) for _, p := range f.Patterns { diff --git a/zenity_test.go b/zenity_test.go index 20802d1..de905d1 100644 --- a/zenity_test.go +++ b/zenity_test.go @@ -56,11 +56,11 @@ func Test_applyOptions(t *testing.T) { {name: "ConfirmCreate", args: ConfirmCreate(), want: options{confirmCreate: true}}, {name: "ShowHidden", args: ShowHidden(), want: options{showHidden: true}}, {name: "Filename", args: Filename("file.go"), want: options{filename: "file.go"}}, - {name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}}, want: options{ - fileFilters: FileFilters{{"Go files", []string{"*.go"}}}, + {name: "FileFilter", args: FileFilter{"Go files", []string{"*.go"}, true}, want: options{ + fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}}, }}, - {name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}}}, want: options{ - fileFilters: FileFilters{{"Go files", []string{"*.go"}}}, + {name: "FileFilters", args: FileFilters{{"Go files", []string{"*.go"}, true}}, want: options{ + fileFilters: FileFilters{{"Go files", []string{"*.go"}, true}}, }}, // Color selection options