diff --git a/example/basic/file-explorer.go b/example/basic/file-explorer.go index 0a88a5b..debfae1 100644 --- a/example/basic/file-explorer.go +++ b/example/basic/file-explorer.go @@ -43,7 +43,7 @@ func (vw *FileExplorerView) Title() string { func (vw *FileExplorerView) Layout(gtx layout.Context, th *theme.Theme) layout.Dimensions { if vw.openFileBtn.Clicked(gtx) { go func() { - reader, _ := fileChooser.ChooseFile() + reader, _ := fileChooser.ChooseFile(".jpg", ".png") defer reader.Close() file := reader.(*os.File) vw.msg1 = file.Name() diff --git a/explorer/explorer.go b/explorer/explorer.go index d38ca79..ae20cd1 100644 --- a/explorer/explorer.go +++ b/explorer/explorer.go @@ -3,7 +3,6 @@ package explorer import ( "image" "image/color" - "io/fs" "os" "path/filepath" "slices" @@ -40,6 +39,7 @@ const ( refreshAction searchAction openFolderAction + addFolderAction selectAction multiSelectAction ) @@ -80,11 +80,12 @@ type history struct { type entryViewer struct { entryTree *EntryNode + entryFilter EntryFilter pendingNext *EntryNode // to prevent list layout conflicts. list *widget.List items []*entryItem // selected items - selectedItems []*entryItem + selectedItems map[*entryItem]struct{} multiSelect bool //panel panel *entryPanel @@ -99,7 +100,10 @@ type entryPanel struct { } type FileExplorer struct { - history *history + history *history + // external entry filter + entryFilter EntryFilter + favorites *favoritesList locations *locationList viewer *entryViewer @@ -132,23 +136,27 @@ func (v volume) Name() string { return filepath.Base(v.mountPoint) } -func newEntryViewer(path string, history *history) *entryViewer { - tree, err := NewFileTree(path, true) +func newEntryViewer(path string, history *history, filter EntryFilter) *entryViewer { + tree, err := NewFileTree(path) if err != nil { panic(err) } + ev := &entryViewer{ - entryTree: tree, + entryTree: tree, + entryFilter: AggregatedFilters(hiddenFileFilter, filter), // search filter should be added dynamically. list: &widget.List{ List: layout.List{ Axis: layout.Vertical, }, }, - panel: &entryPanel{}, - history: history, + panel: &entryPanel{}, + history: history, + selectedItems: make(map[*entryItem]struct{}), } ev.history.Push(ev.entryTree) + tree.Refresh(ev.entryFilter) return ev } @@ -177,12 +185,12 @@ func newFileExplorer() *FileExplorer { func (exp *FileExplorer) Update(gtx C) { if exp.favorites.update(gtx) { - exp.viewer = newEntryViewer(exp.favorites.dirs[exp.favorites.lastSelected], exp.history) + exp.viewer = newEntryViewer(exp.favorites.dirs[exp.favorites.lastSelected], exp.history, exp.entryFilter) exp.locations.lastSelected = -1 } if exp.locations.update(gtx) { - exp.viewer = newEntryViewer(exp.locations.currentVol().mountPoint, exp.history) + exp.viewer = newEntryViewer(exp.locations.currentVol().mountPoint, exp.history, exp.entryFilter) exp.favorites.lastSelected = -1 } } @@ -419,20 +427,15 @@ func (loc *locationList) currentVol() *volume { return loc.volumes[loc.lastSelected] } -func searchFilter(query string) func(info fs.FileInfo) bool { - return func(info fs.FileInfo) bool { - if strings.Contains(info.Name(), query) { - return hiddenFileFilter(info) - } - - return false - } +func (ev *entryViewer) refresh() { + ev.entryTree.Refresh(AggregatedFilters(ev.entryFilter, searchFilter(strings.TrimSpace(ev.panel.searchInput.Text())))) } func (ev *entryViewer) Update(gtx C) { lastTree := ev.entryTree if ev.pendingNext != nil { ev.entryTree = ev.pendingNext + ev.refresh() ev.history.Push(ev.entryTree) ev.pendingNext = nil } @@ -441,12 +444,14 @@ func (ev *entryViewer) Update(gtx C) { switch action { case goBackwardAction: ev.entryTree = ev.history.Backward() + ev.refresh() + case goForwardAction: ev.entryTree = ev.history.Forward() - case refreshAction: - ev.entryTree.Refresh(hiddenFileFilter) - case searchAction: - ev.entryTree.Refresh(searchFilter(strings.TrimSpace(ev.panel.searchInput.Text()))) + ev.refresh() + + case refreshAction, searchAction: + ev.refresh() default: // pass } @@ -485,10 +490,10 @@ func (ev *entryViewer) Layout(gtx C, th *theme.Theme) D { } func (ev *entryViewer) clearSelection() { - for _, item := range ev.selectedItems { + for item := range ev.selectedItems { item.selected = false } - ev.selectedItems = ev.selectedItems[:0] + clear(ev.selectedItems) ev.multiSelect = false } @@ -515,9 +520,9 @@ func (ev *entryViewer) layoutEntries(gtx C, th *theme.Theme) D { } case selectAction: ev.clearSelection() - ev.selectedItems = append(ev.selectedItems, item) + ev.selectedItems[item] = struct{}{} case multiSelectAction: - ev.selectedItems = append(ev.selectedItems, item) + ev.selectedItems[item] = struct{}{} ev.multiSelect = true } diff --git a/explorer/file_chooser.go b/explorer/file_chooser.go index 6d01c6a..23bf113 100644 --- a/explorer/file_chooser.go +++ b/explorer/file_chooser.go @@ -3,8 +3,11 @@ package explorer import ( "errors" "io" + "io/fs" "os" "path/filepath" + "slices" + "strings" "gioui.org/layout" "gioui.org/unit" @@ -43,15 +46,23 @@ type FileChooserDialog struct { *view.BaseView fileExplorer *FileExplorer resultChan chan result + op opKind } type bottomPanel struct { - op opKind - input gvwidget.TextField - confirmBtn widget.Clickable - cancelBtn widget.Clickable - confirmCb func() - cancelCb func() + op opKind + saveFileInput gvwidget.TextField + addFolderInput gvwidget.TextField + addFolderBtn widget.Clickable + cancelAddFolderBtn widget.Clickable + + confirmBtn widget.Clickable + cancelBtn widget.Clickable + isAddingFoder bool + confirmCb func() error + cancelCb func() + addFolderCb func(folderName string) error + err error } func NewFileChooser(vm view.ViewManager) (*FileChooser, error) { @@ -72,11 +83,7 @@ func NewFileChooser(vm view.ViewManager) (*FileChooser, error) { // // It's a blocking call, you should call it on a separated goroutine. func (fc *FileChooser) CreateFile(name string) (io.WriteCloser, error) { - fc.vm.RequestSwitch(view.Intent{ - Target: fileChooserID, - ShowAsModal: true, - Params: map[string]interface{}{"resultChan": fc.resultChan, "op": saveFileOp, "filename": name}, - }) + fc.show(saveFileOp, name) resp := <-fc.resultChan return os.Create(resp.paths[0]) @@ -90,11 +97,7 @@ func (fc *FileChooser) CreateFile(name string) (io.WriteCloser, error) { // Optionally, it's possible to set which file extensions is supported to // be selected (such as `.jpg`, `.png`). func (fc *FileChooser) ChooseFile(extensions ...string) (io.ReadCloser, error) { - fc.vm.RequestSwitch(view.Intent{ - Target: fileChooserID, - ShowAsModal: true, - Params: map[string]interface{}{"resultChan": fc.resultChan, "op": openFileOp}, - }) + fc.show(openFileOp, "", extensions...) resp := <-fc.resultChan return os.Open(resp.paths[0]) @@ -107,11 +110,7 @@ func (fc *FileChooser) ChooseFile(extensions ...string) (io.ReadCloser, error) { // Optionally, it's possible to set which file extensions is supported to // be selected (such as `.jpg`, `.png`). func (fc *FileChooser) ChooseFiles(extensions ...string) ([]io.ReadCloser, error) { - fc.vm.RequestSwitch(view.Intent{ - Target: fileChooserID, - ShowAsModal: true, - Params: map[string]interface{}{"resultChan": fc.resultChan, "op": openFilesOp}, - }) + fc.show(openFilesOp, "", extensions...) resp := <-fc.resultChan readers := make([]io.ReadCloser, len(resp.paths)) @@ -130,14 +129,40 @@ func (fc *FileChooser) ChooseFiles(extensions ...string) ([]io.ReadCloser, error // ChooseFolder shows the file chooser, allowing the user to select a single folder. It returns the folder // path to user. This is a blocking call, you should call it in a seperated goroutine. func (fc *FileChooser) ChooseFolder() (string, error) { + fc.show(openFolderOp, "") + + resp := <-fc.resultChan + return resp.paths[0], nil +} + +func (fc *FileChooser) show(op opKind, filename string, extensions ...string) { + params := map[string]interface{}{"resultChan": fc.resultChan, "op": op} + if op == saveFileOp { + params["filename"] = filename + } + + params["filter"] = chooserFilter(op, extensions...) fc.vm.RequestSwitch(view.Intent{ Target: fileChooserID, ShowAsModal: true, - Params: map[string]interface{}{"resultChan": fc.resultChan, "op": openFolderOp}, + Params: params, }) +} - resp := <-fc.resultChan - return resp.paths[0], nil +func chooserFilter(op opKind, extensions ...string) EntryFilter { + + return func(info fs.FileInfo) bool { + if op == openFolderOp { + return info.IsDir() + } + // In other cases, folder should be kept. + if info.IsDir() || len(extensions) <= 0 { + return true + } + + ext := filepath.Ext(info.Name()) + return slices.ContainsFunc(extensions, func(extension string) bool { return strings.EqualFold(extension, ext) }) + } } func (d *FileChooserDialog) ID() view.ViewID { @@ -145,6 +170,17 @@ func (d *FileChooserDialog) ID() view.ViewID { } func (vw *FileChooserDialog) Title() string { + switch vw.op { + case openFileOp: + return "Open File" + case openFilesOp: + return "Open Files" + case openFolderOp: + return "Open Folder" + case saveFileOp: + return "Save File" + } + return "File Chooser" } @@ -162,28 +198,44 @@ func (vw *FileChooserDialog) OnNavTo(intent view.Intent) error { op := opVal.(opKind) vw.resultChan = rc.(chan result) vw.fileExplorer.bottomPanel.op = op + vw.op = op if op == saveFileOp { param := intent.Params["filename"] - vw.fileExplorer.bottomPanel.input.SetText(param.(string)) + vw.fileExplorer.bottomPanel.saveFileInput.SetText(param.(string)) } + vw.fileExplorer.bottomPanel.addFolderInput.SetText("untitled folder") + vw.fileExplorer.bottomPanel.cancelCb = func() { vw.OnFinish() } - vw.fileExplorer.bottomPanel.confirmCb = func() { + vw.fileExplorer.bottomPanel.confirmCb = func() error { currentPath := vw.fileExplorer.viewer.entryTree.Path switch op { case saveFileOp: - filename := vw.fileExplorer.bottomPanel.input.Text() + filename := strings.TrimSpace(vw.fileExplorer.bottomPanel.saveFileInput.Text()) + if filename == "" { + return errors.New("empty filename") + } + vw.resultChan <- result{paths: []string{filepath.Join(currentPath, filename)}} case openFileOp, openFilesOp, openFolderOp: paths := make([]string, 0) - for _, item := range vw.fileExplorer.viewer.selectedItems { + for item := range vw.fileExplorer.viewer.selectedItems { paths = append(paths, item.node.Path) } vw.resultChan <- result{paths: paths} } vw.OnFinish() + return nil + } + + vw.fileExplorer.bottomPanel.addFolderCb = func(folderName string) error { + return vw.fileExplorer.viewer.entryTree.AddChild(folderName, FolderNode) + } + + if f, ok := intent.Params["filter"]; ok { + vw.fileExplorer.entryFilter = f.(EntryFilter) } return nil @@ -201,11 +253,31 @@ func newFileChooserDialog() view.View { } func (p *bottomPanel) Layout(gtx C, th *theme.Theme) D { + if p.addFolderBtn.Clicked(gtx) { + if p.isAddingFoder { + p.err = p.addFolderCb(p.addFolderInput.Text()) + if p.err == nil { + p.isAddingFoder = false + } + } else { + p.isAddingFoder = true + } + } + if p.cancelAddFolderBtn.Clicked(gtx) { + p.addFolderInput.Clear() + p.err = nil + p.isAddingFoder = false + } + + if p.isAddingFoder && p.err != nil && p.addFolderInput.Changed() { + p.err = nil + } + if p.cancelBtn.Clicked(gtx) { p.cancelCb() } if p.confirmBtn.Clicked(gtx) { - p.confirmCb() + p.err = p.confirmCb() } return layout.Flex{ @@ -214,7 +286,7 @@ func (p *bottomPanel) Layout(gtx C, th *theme.Theme) D { layout.Rigid(func(gtx C) D { switch p.op { case saveFileOp: - return p.layoutInputField(gtx, th) + return p.layoutSaveFileForm(gtx, th) } return D{} @@ -225,36 +297,56 @@ func (p *bottomPanel) Layout(gtx C, th *theme.Theme) D { } return layout.Spacer{Height: unit.Dp(16)}.Layout(gtx) }), + layout.Rigid(func(gtx C) D { + if p.err == nil { + return D{} + } + return misc.LayoutErrorLabel(gtx, th, p.err) + }), layout.Rigid(func(gtx C) D { return layout.Flex{ Axis: layout.Horizontal, Alignment: layout.Middle, - Spacing: layout.SpaceStart, + Spacing: layout.SpaceBetween, }.Layout(gtx, - layout.Rigid(func(gtx C) D { - btn := material.Button(th.Theme, &p.cancelBtn, "Cancel") - btn.Inset = layout.UniformInset(unit.Dp(6)) - btn.Background = th.Bg - btn.Color = th.Fg - return btn.Layout(gtx) + layout.Flexed(1, func(gtx C) D { + return p.layoutAddFolderForm(gtx, th) }), - layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), + + layout.Rigid(layout.Spacer{Width: unit.Dp(32)}.Layout), layout.Rigid(func(gtx C) D { - label := "Open" - if p.op == saveFileOp { - label = "Save" - } - btn := material.Button(th.Theme, &p.confirmBtn, label) - btn.Inset = layout.UniformInset(unit.Dp(6)) - return btn.Layout(gtx) + return layout.Flex{ + Axis: layout.Horizontal, + Alignment: layout.Middle, + Spacing: layout.SpaceStart, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + btn := material.Button(th.Theme, &p.cancelBtn, "Cancel") + btn.Inset = layout.UniformInset(unit.Dp(6)) + btn.Background = th.Bg + btn.Color = th.Fg + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), + + layout.Rigid(func(gtx C) D { + label := "Open" + if p.op == saveFileOp { + label = "Save" + } + btn := material.Button(th.Theme, &p.confirmBtn, label) + btn.Inset = layout.UniformInset(unit.Dp(6)) + return btn.Layout(gtx) + }), + ) }), ) }), ) } -func (p *bottomPanel) layoutInputField(gtx C, th *theme.Theme) D { +func (p *bottomPanel) layoutSaveFileForm(gtx C, th *theme.Theme) D { return layout.Flex{ Axis: layout.Horizontal, Alignment: layout.Middle, @@ -266,10 +358,56 @@ func (p *bottomPanel) layoutInputField(gtx C, th *theme.Theme) D { }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx C) D { - p.input.SingleLine = true - p.input.LabelOption = gvwidget.LabelOption{Alignment: gvwidget.Hidden} - p.input.Padding = unit.Dp(6) - return p.input.Layout(gtx, th, "") + p.saveFileInput.SingleLine = true + p.saveFileInput.LabelOption = gvwidget.LabelOption{Alignment: gvwidget.Hidden} + p.saveFileInput.Padding = unit.Dp(6) + return p.saveFileInput.Layout(gtx, th, "") + }), + ) +} + +func (p *bottomPanel) layoutAddFolderForm(gtx C, th *theme.Theme) D { + btn := "New Folder" + if p.isAddingFoder { + btn = "Save" + } + + return layout.Flex{ + Axis: layout.Horizontal, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(func(gtx C) D { + if !p.isAddingFoder { + return D{} + } + + gtx.Constraints.Max.X = gtx.Dp(unit.Dp(250)) + p.addFolderInput.SingleLine = true + p.addFolderInput.LabelOption = gvwidget.LabelOption{Alignment: gvwidget.Hidden} + p.addFolderInput.Padding = unit.Dp(6) + return p.addFolderInput.Layout(gtx, th, "") + }), + layout.Rigid(func(gtx C) D { + if !p.isAddingFoder { + return D{} + } + return layout.Spacer{Width: unit.Dp(8)}.Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + btn := material.Button(th.Theme, &p.addFolderBtn, btn) + btn.Inset = layout.UniformInset(unit.Dp(6)) + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), + layout.Rigid(func(gtx C) D { + if !p.isAddingFoder { + return D{} + } + btn := material.Button(th.Theme, &p.cancelAddFolderBtn, "Cancel") + btn.Inset = layout.UniformInset(unit.Dp(6)) + btn.Background = th.Bg + btn.Color = th.Fg + return btn.Layout(gtx) }), ) } diff --git a/explorer/tree.go b/explorer/tree.go index ee6ca7c..098838e 100644 --- a/explorer/tree.go +++ b/explorer/tree.go @@ -19,6 +19,11 @@ const ( FolderNode ) +// A filter is used to decide which files/folders are retained when +// buiding a EntryNode's children. Returning false will remove the current +// entry from from the children. +type EntryFilter func(info fs.FileInfo) bool + type EntryNode struct { Path string fs.FileInfo @@ -38,18 +43,40 @@ func hiddenFileFilter(info fs.FileInfo) bool { } } -// Create a new file tree with a relative or absolute rootDir. Folders -// matching prefix in any of the skipPatterns will be skipped. -func NewFileTree(rootDir string, lazyLoad bool) (*EntryNode, error) { +func searchFilter(query string) func(info fs.FileInfo) bool { + return func(info fs.FileInfo) bool { + return strings.Contains(info.Name(), query) + } +} + +func AggregatedFilters(filters ...EntryFilter) EntryFilter { + if len(filters) <= 0 { + return nil + } + + return func(info fs.FileInfo) bool { + for _, filter := range filters { + if filter == nil { + continue + } + + if !filter(info) { + return false + } + } + + return true + } +} + +// Create a new file tree with a relative or absolute rootDir. A filter is used +// to decide which files/folders are retained. +func NewFileTree(rootDir string) (*EntryNode, error) { rootDir, err := filepath.Abs(rootDir) if err != nil { log.Fatalln(err) } - if !lazyLoad { - return loadTree(rootDir) - } - st, err := os.Stat(rootDir) if err != nil { return nil, err @@ -234,7 +261,7 @@ func (n *EntryNode) FileType() string { } // Refresh reload child entries of the current entry node -func (n *EntryNode) Refresh(filterFunc func(entry fs.FileInfo) bool) error { +func (n *EntryNode) Refresh(filterFunc EntryFilter) error { if !n.IsDir() { return nil } diff --git a/explorer/tree_style.go b/explorer/tree_style.go index 6a76422..61c5205 100644 --- a/explorer/tree_style.go +++ b/explorer/tree_style.go @@ -86,7 +86,7 @@ func (tn *FileTreeNav) Layout(gtx C, th *theme.Theme) D { // `menuOptionFunc` is used to define the operations allowed by context menu(use right click to active it). // `onSelectFunc` defines what action to take when a navigable item is clicked (files or folders). func NewEntryNavItem(rootDir string, menuOptionFunc MenuOptionFunc, onSelectFunc OnSelectFunc) *EntryNavItem { - tree, err := NewFileTree(rootDir, true) + tree, err := NewFileTree(rootDir) if err != nil { log.Fatal(err) } diff --git a/widget/editable.go b/widget/editable.go new file mode 100644 index 0000000..f43adac --- /dev/null +++ b/widget/editable.go @@ -0,0 +1,114 @@ +package widget + +import ( + "image" + + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/io/pointer" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/unit" + wg "gioui.org/widget" + "gioui.org/widget/material" + "github.com/oligo/gioview/theme" +) + +// Editable is an editable label that layouts an editor in responds to clicking. +type Editable struct { + Text string + TextSize unit.Sp + OnChanged func(text string) + + editor *wg.Editor + // clickable gesture.Click + hovering bool + editing bool +} + +func (e *Editable) SetEditing(editing bool) { + e.editing = editing +} + +func (e *Editable) Update(gtx C) { + e.editor.SingleLine = true + e.editor.Submit = true + + for { + event, ok := gtx.Event( + key.Filter{Focus: e.editor, Name: key.NameEscape}, + pointer.Filter{Target: e, Kinds: pointer.Enter | pointer.Leave}, + ) + if !ok { + break + } + + switch event := event.(type) { + case key.Event: + if event.Name == key.NameEscape { + e.editing = false + e.editor.SetText(e.Text) + } + case pointer.Event: + switch event.Kind { + case pointer.Enter: + e.hovering = true + case pointer.Leave: + e.hovering = false + case pointer.Cancel: + e.hovering = false + } + } + } + + if e.hovering { + // As an indicator. + pointer.CursorText.Add(gtx.Ops) + } else { + pointer.CursorDefault.Add(gtx.Ops) + } + + // handle editor events: + if ev, ok := e.editor.Update(gtx); ok { + if _, ok := ev.(wg.SubmitEvent); ok { + e.editing = false + e.Text = e.editor.Text() + if e.OnChanged != nil { + e.OnChanged(e.Text) + } + } + } + +} + +func (e *Editable) Layout(gtx C, th *theme.Theme) D { + textSize := e.TextSize + if textSize <= 0 { + textSize = th.TextSize + } + + if e.editing { + return wg.Border{ + Color: th.ContrastBg, + Width: unit.Dp(1), + CornerRadius: unit.Dp(4), + }.Layout(gtx, func(gtx C) D { + return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D { + editor := material.Editor(th.Theme, e.editor, "") + editor.TextSize = textSize + return editor.Layout(gtx) + }) + }) + } + + macro := op.Record(gtx.Ops) + dims := material.Label(th.Theme, textSize, e.Text).Layout(gtx) + callOp := macro.Stop() + + defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop() + event.Op(gtx.Ops, e) + callOp.Add(gtx.Ops) + + return dims +}