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 16 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
231 changes: 200 additions & 31 deletions dialog/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,79 @@ type textWidget interface {
}

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

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

// FileDialog is a dialog containing a file picker for use in opening or saving files.
type FileDialog struct {
save bool
nativePicker bool
callback interface{}
onClosedCallback func(bool)
filter FileFilter
parent fyne.Window
dialog *fileDialog
dismissText string
}

// Declare conformity to Dialog interface
var _ Dialog = (*FileDialog)(nil)

// 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 := mimeTypeSplit(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,28 +107,34 @@ 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()
if f.file.onClosedCallback != nil {
Copy link
Member

Choose a reason for hiding this comment

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

Should this be conditional on file.callback not being nil?

Copy link
Member Author

Choose a reason for hiding this comment

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

file.callback is the read/write closer callback ... file.onClosedCallback is the onClosed callbacks ... They are unrelated as far as I can tell

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh I see you were actually referring to code above. In that space we only hide immediately if there is no file.callback ... so we wouldn't want to call the onClosedCallback until the dialog is closed... in this case when file.callback is not nil, we go through things like validation that may keep the dialog open still - if validation passes we call the Hide() and onClosedCallbacks later in the method...

So long story short - I think this is appropriate.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for the explanation, but I don't quite follow - there is a return here so I don't see where it falls through for the extra processes?

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see what you mean now sorry

f.file.onClosedCallback(false)
}
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)

info, err := os.Stat(path)
if os.IsNotExist(err) {
f.win.Hide()
if f.file.onClosedCallback != nil {
f.file.onClosedCallback(true)
}
callback(storage.SaveFileToURI(storage.NewURI("file://" + path)))
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,27 +147,39 @@ func (f *fileDialog) makeUI() fyne.CanvasObject {

callback(storage.SaveFileToURI(storage.NewURI("file://" + path)))
f.win.Hide()
}, f.parent)
if f.file.onClosedCallback != nil {
f.file.onClosedCallback(true)
}
}, 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()
if f.file.onClosedCallback != nil {
f.file.onClosedCallback(true)
}
callback(storage.OpenFileFromURI(storage.NewURI("file://" + f.selected.path)))
}
})
f.open.Style = widget.PrimaryButton
f.open.Disable()
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)
} else {
f.callback.(func(fyne.FileReadCloser, error))(nil, nil)
}
dismissLabel := "Cancel"
if f.file.dismissText != "" {
dismissLabel = f.file.dismissText
}
f.dismiss = widget.NewButton(dismissLabel, func() {
f.win.Hide()
if f.file.onClosedCallback != nil {
f.file.onClosedCallback(false)
}
if f.file.callback != nil {
if f.file.save {
f.file.callback.(func(fyne.FileWriteCloser, error))(nil, nil)
} else {
f.file.callback.(func(fyne.FileReadCloser, error))(nil, nil)
}
}),
f.open)
}
})
buttons := widget.NewHBox(f.dismiss, f.open)
footer := fyne.NewContainerWithLayout(layout.NewBorderLayout(nil, nil, nil, buttons),
buttons, widget.NewHScrollContainer(f.fileName))

Expand Down Expand Up @@ -165,11 +240,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 +306,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 +319,122 @@ 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
}

// Show shows the file dialog.
func (f *FileDialog) Show() {
if f.save {
if fileSaveOSOverride(f.callback.(func(fyne.FileWriteCloser, error)), f.parent) {
f.nativePicker = true
return
}
} else {
if fileOpenOSOverride(f.callback.(func(fyne.FileReadCloser, error)), f.parent) {
f.nativePicker = true
return
}
}
if f.dialog != nil {
f.dialog.win.Show()
return
}
f.dialog = showFile(f)
}

// Hide hides the file dialog.
func (f *FileDialog) Hide() {
if f.nativePicker {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
return
}
f.dialog.win.Hide()
if f.onClosedCallback != nil {
f.onClosedCallback(false)
}
}

// SetDismissText allows custom text to be set in the confirmation button
func (f *FileDialog) SetDismissText(label string) {
if f.nativePicker {
okratitan marked this conversation as resolved.
Show resolved Hide resolved
return
}
f.dialog.dismiss.SetText(label)
widget.Refresh(f.dialog.win)
}

// SetOnClosed sets a callback function that is called when
// the dialog is closed.
func (f *FileDialog) SetOnClosed(closed func()) {
// If there is already a callback set, remember it and call both.
okratitan marked this conversation as resolved.
Show resolved Hide resolved
if f.nativePicker {
return
}
originalCallback := f.onClosedCallback

f.onClosedCallback = func(response bool) {
closed()
if originalCallback != nil {
originalCallback(response)
}
}
}

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

// 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 shows a file dialog allowing the user to choose a file to open.
// 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
}
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
}
showFileDialog(true, callback, parent)
dialog.Show()
}