Files
MujicaUI/file_explorer.go
T

255 lines
7.6 KiB
Go

package mujica
import (
"log/slog"
"path"
"runtime"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// FileEntry identifies a file or folder by slash-separated Path.
type FileEntry struct {
Path, Name string
Dir bool
Size int64
Modified time.Time
}
// FileExplorerOptions configure a FileExplorer.
type FileExplorerOptions struct {
// Roots are the top folders of the tree.
Roots []string
// Folders returns subfolders; required, nil means a leaf.
Folders func(dir string) []string
// Entries returns a folder's files and folders; required.
Entries func(dir string) []FileEntry
Disabled bool
// Revision changes when entries are edited in place.
Revision uint64
}
// FileExplorerState keeps navigation, selection and scroll position.
type FileExplorerState struct {
// Current defaults to the first root.
Current string
tree TreeState[string]
list ListState[string]
back, forward []string
data *FileEntry
n int
revision uint64
}
// Selected returns the chosen entries' paths.
func (s *FileExplorerState) Selected() []string {
var out []string
for p := range s.list.Selection.All() {
out = append(out, p)
}
return out
}
// Navigate records the current folder and shows dir.
func (s *FileExplorerState) Navigate(dir string) {
if dir == s.Current {
return
}
if s.Current != "" {
s.back = append(s.back, s.Current)
}
s.Current, s.forward = dir, nil
s.list.Selection.Clear()
slog.Debug("mujica: file_explorer navigate", "depth", strings.Count(dir, "/"))
}
func (s *FileExplorerState) step(from, to *[]string) {
n := len(*from)
if n == 0 {
return
}
*to = append(*to, s.Current)
s.Current, *from = (*from)[n-1], (*from)[:n-1]
s.list.Selection.Clear()
}
// FileExplorerResult is a FileExplorer and what happened in this frame.
type FileExplorerResult struct {
Element *ui.Element
// Navigated reports a new current folder; Changed a new selection.
Navigated, Changed bool
// Opened is a file the user opened.
Opened *FileEntry
}
// FileExplorer combines folder navigation, history and file selection.
func FileExplorer(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions) FileExplorerResult {
if s == nil || opts.Folders == nil || opts.Entries == nil || len(opts.Roots) == 0 {
panic("mujica: FileExplorer needs a state, Roots, Folders and Entries")
}
if s.Current == "" {
s.Current = opts.Roots[0]
}
k := Tokens(c)
u := Density(c).Unit()
var res FileExplorerResult
res.Element = ui.Column(c).MinHeight(0).Gap(u * 2).Disabled(opts.Disabled)
disabled := res.Element.IsDisabled()
if disabled {
copy := *s
copy.list.Selection = filesSelectionCopy(s.list.Selection)
copy.tree.Selection = filesSelectionCopy(s.tree.Selection)
copy.tree.tree.outline.Open = filesSelectionCopy(s.tree.tree.outline.Open)
s = &copy
}
at := s.Current
move := func(fn func()) {
if !disabled {
fn()
}
}
mac := runtime.GOOS == "darwin"
switch {
case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketLeft), !mac && res.Element.Shortcut(ui.Alt, ui.KeyLeft):
move(func() { s.step(&s.back, &s.forward) })
case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketRight), !mac && res.Element.Shortcut(ui.Alt, ui.KeyRight):
move(func() { s.step(&s.forward, &s.back) })
case mac && res.Element.Shortcut(ui.Super, ui.KeyUp), !mac && res.Element.Shortcut(ui.Alt, ui.KeyUp):
move(func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) })
}
res.Element.Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).Children(func() {
for _, b := range []struct {
icon, key string
on bool
do func()
}{
{"chevron-left", "file_explorer.back", len(s.back) > 0, func() { s.step(&s.back, &s.forward) }},
{"chevron-right", "file_explorer.forward", len(s.forward) > 0, func() { s.step(&s.forward, &s.back) }},
{"arrow-up", "file_explorer.up", fileExplorerParent(s.Current, opts.Roots) != s.Current, func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) }},
} {
a := iconAction(c, icons.Must(b.icon), msg(c, b.key)).Disabled(!b.on)
if a.Clicked() && b.on {
move(b.do)
}
}
if r := PathBar(c, s.Current, PathBarOptions{Disabled: opts.Disabled}); r.Navigated != "" {
move(func() { s.Navigate(r.Navigated) })
}
})
ui.Row(c).Grow(1).MinHeight(Density(c).ControlHeight() * 4).Gap(u * 2).AlignItems(ui.Stretch).Children(func() {
fileExplorerTree(c, s, opts, move)
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
entries := opts.Entries(s.Current)
var first *FileEntry
if len(entries) > 0 {
first = &entries[0]
}
if s.data != first || s.n != len(entries) || s.revision != opts.Revision {
valid := make(map[string]bool, len(entries))
for _, entry := range entries {
if entry.Path == "" || valid[entry.Path] {
panic("mujica: FileExplorer needs unique entry paths")
}
valid[entry.Path] = true
}
for key := range s.list.Selection.All() {
if !valid[key] {
s.list.Selection.Remove(key)
}
}
s.data, s.n, s.revision = first, len(entries), opts.Revision
}
v := List(c, &s.list, len(entries), ListOptions[string]{Multiple: true,
Key: func(i int) string { return entries[i].Path }, Label: func(i int) string { return entries[i].Name },
Empty: msg(c, "file_explorer.empty")}, func(i int) { fileExplorerRow(c, k, entries[i]) })
v.Element.Grow(1)
res.Changed = v.Changed() && !disabled
if key, ok := s.list.Selected(); ok && v.Submitted() && !disabled {
for _, e := range entries {
if e.Path != key {
continue
}
if e.Dir {
s.Navigate(e.Path)
} else {
open := e
res.Opened = &open
slog.Debug("mujica: file_explorer open")
}
}
}
})
})
})
res.Navigated = s.Current != at
return res
}
func filesSelectionCopy(src ui.Selection[string]) ui.Selection[string] {
var dst ui.Selection[string]
for key := range src.All() {
dst.Add(key)
}
return dst
}
// fileExplorerTree shows the folders, choosing the current one.
func fileExplorerTree(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions, move func(func())) {
for _, a := range fileExplorerAncestors(s.Current, opts.Roots) {
s.tree.SetOpen(a, true)
}
s.tree.tree.sel, s.tree.tree.has = s.Current, true
v := Tree(c, &s.tree, TreeOptions[string]{Roots: opts.Roots, Children: opts.Folders,
Label: func(p string) string { return path.Base(p) }})
v.Element.Width(220).Shrink(0)
if v.Changed() {
if dir, ok := s.tree.Selected(); ok && dir != s.Current {
move(func() { s.Navigate(dir) })
}
}
}
// fileExplorerAncestors lists the folders above dir, inside its root.
func fileExplorerAncestors(dir string, roots []string) []string {
var out []string
for p := dir; ; {
parent := fileExplorerParent(p, roots)
if parent == p {
return out
}
out = append(out, parent)
p = parent
}
}
// fileExplorerParent is dir's parent, or dir itself at a root.
func fileExplorerParent(dir string, roots []string) string {
for _, r := range roots {
if dir == r {
return dir
}
}
p := path.Dir(dir)
if p == "." || p == dir {
return dir
}
return p
}
func fileExplorerRow(c *ui.Context, k theme.Tokens, e FileEntry) {
FileIcon(c, e.Name, FileIconOptions{Folder: e.Dir})
ui.Text(c, e.Name).SingleLine().Grow(1).MinWidth(0)
if !e.Dir {
ui.Text(c, processListBytes(uint64(max(e.Size, 0)))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0)
}
if !e.Modified.IsZero() {
ui.Text(c, e.Modified.Format("2006-01-02 15:04")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0)
}
}