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 14 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
24 changes: 24 additions & 0 deletions cmd/fyne_demo/screens/window.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ func loadDialogGroup(win fyne.Window) *widget.Group {
fileOpened(reader)
}, win)
}),
widget.NewButton("File Open With Filter (only show txt or png)", func() {
fd := dialog.NewFileOpen(func(reader fyne.FileReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}

fileOpened(reader)
}, win)
fd.SetFilter(dialog.NewExtensionFileFilter([]string{".png", ".txt"}))
fd.Show()
}),
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 +167,18 @@ func loadDialogGroup(win fyne.Window) *widget.Group {
fileSaved(writer)
}, win)
}),
widget.NewButton("File Save With Filter (only show images)", func() {
fd := dialog.NewFileSave(func(writer fyne.FileWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}

fileSaved(writer)
}, win)
fd.SetFilter(dialog.NewMimeTypeFileFilter([]string{"image/*"}))
fd.Show()
}),
widget.NewButton("Custom Dialog (Login Form)", func() {
username := widget.NewEntry()
password := widget.NewPasswordEntry()
Expand Down
152 changes: 127 additions & 25 deletions dialog/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,72 @@ type textWidget interface {
}

type fileDialog struct {
file *FileDialog
fileName textWidget
open *widget.Button
breadcrumb *widget.Box
files *fyne.Container
fileScroll *widget.ScrollContainer
parent fyne.Window

win *widget.PopUp
selected *fileDialogItem
callback interface{}
dir string
}

// FileDialog is a dialog containing a file picker for use in opening or saving files.
type FileDialog struct {
save bool
callback interface{}
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
parent fyne.Window
dialog *fileDialog
}

// FileFilter is an interface that can be implemented to provide a filter to a file dialog.
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.
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.
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 := strings.Split(mimeTypeSplit[1], ";")[0]
if mType == mimeType {
if mSubType == mimeSubType || mSubType == "*" {
return true
}
}
}
return false
}

func (f *fileDialog) makeUI() fyne.CanvasObject {
if f.save {
if f.file.save {
saveName := widget.NewEntry()
saveName.OnChanged = func(s string) {
if s == "" {
Expand All @@ -50,17 +100,17 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
}

label := "Open"
if f.save {
if f.file.save {
label = "Save"
}
f.open = widget.NewButton(label, func() {
if f.callback == nil {
if f.file.callback == nil {
f.win.Hide()
return
}

if f.save {
callback := f.callback.(func(fyne.FileWriteCloser, error))
if f.file.save {
callback := f.file.callback.(func(fyne.FileWriteCloser, error))
name := f.fileName.(*widget.Entry).Text
path := filepath.Join(f.dir, name)

Expand All @@ -71,7 +121,7 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
return
} else if info.IsDir() {
ShowInformation("Cannot overwrite",
"Files cannot replace a directory,\ncheck the file name and try again", f.parent)
"Files cannot replace a directory,\ncheck the file name and try again", f.file.parent)
return
}

Expand All @@ -84,9 +134,9 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {

callback(storage.SaveFileToURI(storage.NewURI("file://" + path)))
f.win.Hide()
}, f.parent)
}, f.file.parent)
} else if f.selected != nil {
callback := f.callback.(func(fyne.FileReadCloser, error))
callback := f.file.callback.(func(fyne.FileReadCloser, error))
f.win.Hide()
callback(storage.OpenFileFromURI(storage.NewURI("file://" + f.selected.path)))
}
Expand All @@ -96,11 +146,11 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {
buttons := widget.NewHBox(
widget.NewButton("Cancel", func() {
f.win.Hide()
if f.callback != nil {
if f.save {
f.callback.(func(fyne.FileWriteCloser, error))(nil, nil)
if f.file.callback != nil {
if f.file.save {
f.file.callback.(func(fyne.FileWriteCloser, error))(nil, nil)
} else {
f.callback.(func(fyne.FileReadCloser, error))(nil, nil)
f.file.callback.(func(fyne.FileReadCloser, error))(nil, nil)
}
}
}),
Expand Down Expand Up @@ -165,11 +215,10 @@ 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 {
} else if f.file.filter == nil || f.file.filter.Matches(storage.NewURI("file://"+itemPath)) {
icons = append(icons, f.newFileItem(itemPath, false))
}
}
Expand Down Expand Up @@ -232,8 +281,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 showFile(file *FileDialog) *fileDialog {
d := &fileDialog{file: file}
ui := d.makeUI()
dir, err := os.UserHomeDir()
if err != nil {
Expand All @@ -245,27 +294,80 @@ func showFileDialog(save bool, callback interface{}, parent fyne.Window) {
size := ui.MinSize().Add(fyne.NewSize(fileIconCellWidth*2+theme.Padding()*4,
(fileIconSize+fileTextSize)+theme.Padding()*4))

d.win = widget.NewModalPopUp(ui, parent.Canvas())
d.win = widget.NewModalPopUp(ui, file.parent.Canvas())
d.win.Resize(size)

d.win.Show()
return d
}

// SetFilter sets a filter for limiting files that can be chosen in the file dialog.
func (f *FileDialog) SetFilter(filter FileFilter) {
f.filter = filter
if f.dialog != nil {
f.dialog.refreshDir(f.dialog.dir)
}
}

// ShowFileOpen shows a file dialog allowing the user to choose a file to open.
// Show shows the file dialog
func (f *FileDialog) Show() {
if !f.save {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if fileOpenOSOverride(f.callback.(func(fyne.FileReadCloser, error)), f.parent) {
return
}
else {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if fileSaveOSOverride(f.callback.(func(fyne.FileWriteCloser, error)), f.parent) {
return
}
}

f.dialog = showFile(f)
}

// 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}
}

// NewFileOpen creates a file dialog allowing the user to choose a file to open.
// The dialog will appear over the window specified when Show() is called.
func NewFileOpen(callback func(fyne.FileReadCloser, error), parent fyne.Window) *FileDialog {
dialog := &FileDialog{callback: callback, parent: parent}
return dialog
}

// NewFileSave creates a file dialog allowing the user 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 when Show() is called.
func NewFileSave(callback func(fyne.FileWriteCloser, error), parent fyne.Window) *FileDialog {
dialog := &FileDialog{callback: callback, parent: parent, save: true}
return dialog
}

// ShowFileOpen creates and 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
dialog := NewFileOpen(callback, parent)
if fileOpenOSOverride(callback, parent) {
return
return nil
}
showFileDialog(false, callback, parent)
dialog.Show()
}

// ShowFileSave shows a file dialog allowing the user to choose a file to save to (new or overwrite).
// ShowFileSave creates and shows a file dialog allowing the user 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 ShowFileSave(callback func(fyne.FileWriteCloser, error), parent fyne.Window) {
dialog := NewFileSave(callback, parent)
if fileSaveOSOverride(callback, parent) {
return
return nil
}
showFileDialog(true, callback, parent)
dialog.Show()
}
55 changes: 55 additions & 0 deletions dialog/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"log"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"

"fyne.io/fyne"
"fyne.io/fyne/storage"
"fyne.io/fyne/test"
"fyne.io/fyne/widget"
)
Expand Down Expand Up @@ -133,3 +135,56 @@ func TestShowFileSave(t *testing.T) {
err = os.Remove(expectedPath)
assert.Nil(t, err)
}

func TestFileFilters(t *testing.T) {
win := test.NewWindow(widget.NewLabel("Content"))
f := NewFileOpen(func(file fyne.FileReadCloser, err error) {
}, win)

f.SetFilter(NewExtensionFileFilter([]string{".png"}))
f.Show()

workingDir, err := os.Getwd()
if err != nil {
fyne.LogError("Could not get current working directory", err)
t.FailNow()
}
testDataDir := filepath.Join(workingDir, "testdata")

f.dialog.setDirectory(testDataDir)

count := 0
for _, icon := range f.dialog.files.Objects {
if icon.(*fileDialogItem).dir == false {
uri := storage.NewURI("file://" + icon.(*fileDialogItem).path)
assert.Equal(t, uri.Extension(), ".png")
count++
}
}
assert.Equal(t, count, 1)

f.SetFilter(NewMimeTypeFileFilter([]string{"image/jpeg"}))

count = 0
for _, icon := range f.dialog.files.Objects {
if icon.(*fileDialogItem).dir == false {
uri := storage.NewURI("file://" + icon.(*fileDialogItem).path)
assert.Equal(t, uri.MimeType(), "image/jpeg")
count++
}
}
assert.Equal(t, count, 1)

f.SetFilter(NewMimeTypeFileFilter([]string{"image/*"}))

count = 0
for _, icon := range f.dialog.files.Objects {
if icon.(*fileDialogItem).dir == false {
uri := storage.NewURI("file://" + icon.(*fileDialogItem).path)
mimeType := strings.Split(uri.MimeType(), "/")[0]
assert.Equal(t, mimeType, "image")
count++
}
}
assert.Equal(t, count, 2)
}
11 changes: 5 additions & 6 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,10 @@ 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)
mimeType, mimeSubType := mimeTypeGet(uri)
ext := uri.Extension()

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

func mimeTypeGet(path string) (ext, mimeType, mimeSubType string) {
uri := storage.NewURI(path)
ext = uri.Extension()
func mimeTypeGet(uri fyne.URI) (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