Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to filter files in the File Dialog #1034

Merged
merged 19 commits into from
May 28, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d679ae
Add the ability to filter files in the File Dialog
okratitan May 24, 2020
586c111
Use a separate constructor for file dialog with filters.
okratitan May 24, 2020
03d7d80
Make NewFileIcon take a fyne.URI instead of a path string.
okratitan May 24, 2020
92da698
Move the file dialog to use a New/Show pattern for its api.
okratitan May 25, 2020
f42b46b
Strip possible parameters off of user provide mimetypes and clean mat…
okratitan May 25, 2020
014cb9a
Add tests for file dialog filtering.
okratitan May 26, 2020
d2b47ac
Rename FileDialog to File and Remove Duplication between File and fil…
okratitan May 26, 2020
25dcf5e
Remove dialog suffix and add back in Show... convenience functions.
okratitan May 26, 2020
8b60b8f
Make sure to prepend "file://" to new URIs.
okratitan May 26, 2020
69ea193
Fix mimetype file reading to work with uris.
okratitan May 26, 2020
eaf2009
Include the fyne test import
May 26, 2020
d0c5e6c
Setup the uri test in it's own package and import fyne test
May 26, 2020
83503d8
Use real image files for dialog test data.
okratitan May 27, 2020
7881477
Remove extension from return of mimeTypeGet and fix
May 27, 2020
8409fcb
Implement Dialog and rename mimeTypeGet to mimeTypeSplit
okratitan May 27, 2020
1db0567
Rename label for dismiss text to avoid test failure.
okratitan May 27, 2020
177ec83
Rename mimeTypeSplit to splitMimeType and remove File.nativePicker
okratitan May 28, 2020
a96ec41
Move comment to the correct place
okratitan May 28, 2020
22685b3
Merge branch 'develop' into fileFilter
okratitan May 28, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 20 additions & 0 deletions cmd/fyne_demo/screens/window.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ func loadDialogGroup(win fyne.Window) *widget.Group {
fileOpened(reader)
}, win)
}),
widget.NewButton("File Open With Filter (only show txt or png)", func() {
dialog.ShowFileOpenWithFilter(func(reader fyne.FileReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}

fileOpened(reader)
}, win, dialog.NewExtensionFileFilter([]string{".png", ".txt"}))
}),
widget.NewButton("File Save", func() {
dialog.ShowFileSave(func(writer fyne.FileWriteCloser, err error) {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
Expand All @@ -155,6 +165,16 @@ func loadDialogGroup(win fyne.Window) *widget.Group {
fileSaved(writer)
}, win)
}),
widget.NewButton("File Save With Filter (only show images)", func() {
dialog.ShowFileSaveWithFilter(func(writer fyne.FileWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}

fileSaved(writer)
}, win, dialog.NewMimeTypeFileFilter([]string{"image/*"}))
}),
widget.NewButton("Custom Dialog (Login Form)", func() {
username := widget.NewEntry()
password := widget.NewPasswordEntry()
Expand Down
87 changes: 82 additions & 5 deletions dialog/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,54 @@ type fileDialog struct {
win *widget.PopUp
selected *fileDialogItem
callback interface{}
okratitan marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should have openCallback and saveCallback to avoid the type casts later on

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind the type casts later, but perhaps others may have some thought here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolving this to see if others have an opinion here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see that this had been reopened, sorry for that.
I agree that it could be improved, it has evolved a little since the first commit and we learned a lot along the way. Let's do a follow-on following discussion in Slack

filter FileFilter
dir string
save bool
}

// FileFilter is an interface that can be implemented to provide a filter to a file dialog
okratitan marked this conversation as resolved.
Show resolved Hide resolved
type FileFilter interface {
Matches(fyne.URI) bool
}

type extensionFileFilter struct {
extensions []string
}

type mimeTypeFileFilter struct {
mimeTypes []string
}

// Matches returns true if a file URI has one of the filtered extensions
okratitan marked this conversation as resolved.
Show resolved Hide resolved
func (e *extensionFileFilter) Matches(uri fyne.URI) bool {
extension := uri.Extension()
for _, ext := range e.extensions {
if extension == ext {
return true
}
}
return false
}

// Matches returns true if a file URI has one of the filtered mimetypes
okratitan marked this conversation as resolved.
Show resolved Hide resolved
func (mt *mimeTypeFileFilter) Matches(uri fyne.URI) bool {
_, mimeType, mimeSubType := mimeTypeGet(uri)
for _, mimeTypeFull := range mt.mimeTypes {
mimeTypeSplit := strings.Split(mimeTypeFull, "/")
if len(mimeTypeSplit) <= 1 {
continue
}
mType := mimeTypeSplit[0]
mSubType := mimeTypeSplit[1]
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if mType == mimeType {
if mSubType == mimeSubType || mSubType == "*" {
return true
}
}
}
return false
}

func (f *fileDialog) makeUI() fyne.CanvasObject {
if f.save {
saveName := widget.NewEntry()
Expand Down Expand Up @@ -165,11 +209,13 @@ func (f *fileDialog) refreshDir(dir string) {
if isHidden(file.Name(), dir) {
continue
}

itemPath := filepath.Join(dir, file.Name())
if file.IsDir() {
icons = append(icons, f.newFileItem(itemPath, true))
} else {
if f.filter != nil && !f.filter.Matches(storage.NewURI(itemPath)) {
continue
}
icons = append(icons, f.newFileItem(itemPath, false))
}
okratitan marked this conversation as resolved.
Show resolved Hide resolved
}
Expand Down Expand Up @@ -232,8 +278,8 @@ func (f *fileDialog) setSelected(file *fileDialogItem) {
}
}

func showFileDialog(save bool, callback interface{}, parent fyne.Window) {
d := &fileDialog{callback: callback, save: save, parent: parent}
func showFileDialog(save bool, callback interface{}, parent fyne.Window, filter FileFilter) {
d := &fileDialog{callback: callback, save: save, parent: parent, filter: filter}
ui := d.makeUI()
dir, err := os.UserHomeDir()
if err != nil {
Expand All @@ -251,13 +297,34 @@ func showFileDialog(save bool, callback interface{}, parent fyne.Window) {
d.win.Show()
}

// NewExtensionFileFilter takes a string slice of extensions with a leading . and creates a filter for the file dialog.
// Example: .jpg, .mp3, .txt, .sh
func NewExtensionFileFilter(extensions []string) FileFilter {
return &extensionFileFilter{extensions: extensions}
}

// NewMimeTypeFileFilter takes a string slice of mimetypes, including globs, and creates a filter for the file dialog.
// Example: image/*, audio/mp3, text/plain, application/*
func NewMimeTypeFileFilter(mimeTypes []string) FileFilter {
return &mimeTypeFileFilter{mimeTypes: mimeTypes}
}

// ShowFileOpen shows a file dialog allowing the user to choose a file to open.
// The dialog will appear over the window specified.
func ShowFileOpen(callback func(fyne.FileReadCloser, error), parent fyne.Window) {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if fileOpenOSOverride(callback, parent) {
return
}
showFileDialog(false, callback, parent)
showFileDialog(false, callback, parent, nil)
}

// ShowFileOpenWithFilter shows a file dialog with a filter limiting displayed files to choose a file to open.
// The dialog will appear over the window specified.
func ShowFileOpenWithFilter(callback func(fyne.FileReadCloser, error), parent fyne.Window, filter FileFilter) {
if fileOpenOSOverride(callback, parent) {
return
}
showFileDialog(false, callback, parent, filter)
}

// ShowFileSave shows a file dialog allowing the user to choose a file to save to (new or overwrite).
Expand All @@ -267,5 +334,15 @@ func ShowFileSave(callback func(fyne.FileWriteCloser, error), parent fyne.Window
if fileSaveOSOverride(callback, parent) {
return
}
showFileDialog(true, callback, parent)
showFileDialog(true, callback, parent, nil)
}

// ShowFileSaveWithFilter shows a file dialog with a filter limiting displayed files to choose a file to save to (new or overwrite).
// If the user chooses an existing file they will be asked if they are sure.
// The dialog will appear over the window specified.
func ShowFileSaveWithFilter(callback func(fyne.FileWriteCloser, error), parent fyne.Window, filter FileFilter) {
if fileSaveOSOverride(callback, parent) {
return
}
showFileDialog(true, callback, parent, filter)
}
8 changes: 3 additions & 5 deletions dialog/fileicon.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (

"fyne.io/fyne"
"fyne.io/fyne/canvas"
"fyne.io/fyne/storage"
"fyne.io/fyne/theme"
"fyne.io/fyne/widget"
)
Expand All @@ -29,9 +28,9 @@ type fileIconRenderer struct {
const ratioDown float64 = 0.45

// NewFileIcon takes a filepath and creates an icon with an overlayed label using the detected mimetype and extension
func NewFileIcon(path string) fyne.CanvasObject {
func NewFileIcon(uri fyne.URI) fyne.CanvasObject {

ext, mimeType, mimeSubType := mimeTypeGet(path)
ext, mimeType, mimeSubType := mimeTypeGet(uri)

var res fyne.Resource
switch mimeType {
Expand Down Expand Up @@ -97,8 +96,7 @@ func (s fileIconRenderer) Refresh() {
canvas.Refresh(s.item)
}

func mimeTypeGet(path string) (ext, mimeType, mimeSubType string) {
uri := storage.NewURI(path)
func mimeTypeGet(uri fyne.URI) (ext, mimeType, mimeSubType string) {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
ext = uri.Extension()
if len(ext) > 5 {
ext = ext[:5]
Expand Down
15 changes: 8 additions & 7 deletions dialog/fileicon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,43 @@ import (
"github.com/stretchr/testify/assert"

"fyne.io/fyne"
"fyne.io/fyne/storage"
"fyne.io/fyne/theme"
)

func TestNewFileIcon(t *testing.T) {
f := &fileDialog{}
_ = f.makeUI()

item := NewFileIcon("/path/to/filename.zip")
item := NewFileIcon(storage.NewURI("/path/to/filename.zip"))
okratitan marked this conversation as resolved.
Show resolved Hide resolved

assert.Equal(t, item.(*fileIcon).extension, ".zip")
assert.Equal(t, item.(*fileIcon).mimeType, "application")
assert.Equal(t, item.(*fileIcon).mimeSubType, "zip")
assert.Equal(t, item.(*fileIcon).resource, theme.FileApplicationIcon())

item = NewFileIcon("/path/to/filename.mp3")
item = NewFileIcon(storage.NewURI("/path/to/filename.mp3"))

assert.Equal(t, item.(*fileIcon).extension, ".mp3")
assert.Equal(t, item.(*fileIcon).mimeType, "audio")
assert.Equal(t, item.(*fileIcon).mimeSubType, "mpeg")
assert.Equal(t, item.(*fileIcon).resource, theme.FileAudioIcon())

item = NewFileIcon("/path/to/filename.png")
item = NewFileIcon(storage.NewURI("/path/to/filename.png"))

assert.Equal(t, item.(*fileIcon).extension, ".png")
assert.Equal(t, item.(*fileIcon).mimeType, "image")
assert.Equal(t, item.(*fileIcon).mimeSubType, "png")
assert.Equal(t, item.(*fileIcon).resource, theme.FileImageIcon())

item = NewFileIcon("/path/to/filename.txt")
item = NewFileIcon(storage.NewURI("/path/to/filename.txt"))

assert.Equal(t, item.(*fileIcon).extension, ".txt")
assert.Equal(t, item.(*fileIcon).mimeType, "text")
assert.Equal(t, item.(*fileIcon).mimeSubType, "plain")
assert.Equal(t, item.(*fileIcon).resource, theme.FileTextIcon())

item = NewFileIcon("/path/to/filename.mp4")
item = NewFileIcon(storage.NewURI("/path/to/filename.mp4"))

assert.Equal(t, item.(*fileIcon).extension, ".mp4")
assert.Equal(t, item.(*fileIcon).mimeType, "video")
Expand All @@ -62,14 +63,14 @@ func TestNewFileIconNoExtension(t *testing.T) {
binFileWithNoExt := filepath.Join(workingDir, "testdata/bin")
textFileWithNoExt := filepath.Join(workingDir, "testdata/text")

item := NewFileIcon(binFileWithNoExt)
item := NewFileIcon(storage.NewURI(binFileWithNoExt))

assert.Equal(t, item.(*fileIcon).extension, "")
assert.Equal(t, item.(*fileIcon).mimeType, "application")
assert.Equal(t, item.(*fileIcon).mimeSubType, "octet-stream")
assert.Equal(t, item.(*fileIcon).resource, theme.FileApplicationIcon())

item = NewFileIcon(textFileWithNoExt)
item = NewFileIcon(storage.NewURI(textFileWithNoExt))

assert.Equal(t, item.(*fileIcon).extension, "")
assert.Equal(t, item.(*fileIcon).mimeType, "text")
Expand Down
3 changes: 2 additions & 1 deletion dialog/fileitem.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"fyne.io/fyne"
"fyne.io/fyne/canvas"
"fyne.io/fyne/storage"
"fyne.io/fyne/theme"
"fyne.io/fyne/widget"
)
Expand Down Expand Up @@ -60,7 +61,7 @@ func (f *fileDialog) newFileItem(path string, dir bool) *fileDialogItem {
if dir {
icon = canvas.NewImageFromResource(theme.FolderIcon())
} else {
icon = NewFileIcon(path)
icon = NewFileIcon(storage.NewURI(path))
okratitan marked this conversation as resolved.
Show resolved Hide resolved
}
name := fileName(path)

Expand Down