293 lines
8.9 KiB
Go
293 lines
8.9 KiB
Go
package mujica
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/egoist/mygo"
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// DroppedFile is a file a FileDropZone accepted.
|
|
type DroppedFile struct {
|
|
Path string
|
|
Size int64
|
|
}
|
|
|
|
// FileDropZoneOptions configure a FileDropZone. Zero limits accept anything.
|
|
type FileDropZoneOptions struct {
|
|
// Extensions lists the accepted extensions without the dot, matched case-insensitively.
|
|
Extensions []string
|
|
// MaxFiles caps how many files the list holds.
|
|
MaxFiles int
|
|
// MaxSize caps each file's size in bytes.
|
|
MaxSize int64
|
|
// Hint replaces the built-in "Drop files here".
|
|
Hint string
|
|
Disabled bool
|
|
}
|
|
|
|
// FileDropZoneParts are the parts of a FileDropZone.
|
|
type FileDropZoneParts struct {
|
|
Element *ui.Element
|
|
changed bool
|
|
}
|
|
|
|
// Changed reports whether files were added or removed this frame.
|
|
func (p FileDropZoneParts) Changed() bool { return p.changed }
|
|
|
|
// fileDropZoneReject is a file that failed a check, shown until dismissed.
|
|
type fileDropZoneReject struct {
|
|
path, reason string
|
|
}
|
|
|
|
type fileDropZoneStat struct {
|
|
path string
|
|
size int64
|
|
dir bool
|
|
err error
|
|
}
|
|
|
|
type fileDropZoneState struct {
|
|
pending []string
|
|
stats chan []fileDropZoneStat
|
|
browse chan filePickerResult
|
|
rejected []fileDropZoneReject
|
|
}
|
|
|
|
// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them.
|
|
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
|
|
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
|
|
panic("mujica: FileDropZone limits must not be negative")
|
|
}
|
|
for _, ext := range opts.Extensions {
|
|
if ext == "" || strings.HasPrefix(ext, ".") {
|
|
panic(fmt.Sprintf("mujica: FileDropZone extension %q: give it without the dot", ext))
|
|
}
|
|
}
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
parts := FileDropZoneParts{}
|
|
col := ui.Column(c).Gap(d.Unit() * 2)
|
|
parts.Element = col
|
|
st := ui.Local(col, "zone", func() fileDropZoneState { return fileDropZoneState{} })
|
|
reject := func(path, key string, args ...any) {
|
|
st.rejected = append(st.rejected, fileDropZoneReject{path, fmt.Sprintf(msg(c, key), args...)})
|
|
slog.Debug("mujica: file drop zone rejected", "reason", key)
|
|
}
|
|
// take checks types now and sizes off the UI thread.
|
|
take := func(paths []string) {
|
|
var ok []string
|
|
for _, p := range paths {
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
|
|
switch {
|
|
case slices.ContainsFunc(*files, func(f DroppedFile) bool { return f.Path == p }), slices.Contains(st.pending, p), slices.Contains(ok, p):
|
|
case len(opts.Extensions) > 0 && !slices.ContainsFunc(opts.Extensions, func(e string) bool { return strings.EqualFold(e, ext) }):
|
|
reject(p, "file_drop_zone.type", strings.Join(opts.Extensions, ", "))
|
|
default:
|
|
ok = append(ok, p)
|
|
}
|
|
}
|
|
if len(ok) == 0 {
|
|
return
|
|
}
|
|
st.pending = append(st.pending, ok...)
|
|
if st.stats != nil {
|
|
return // the running check picks them up next
|
|
}
|
|
fileDropZoneCheck(c, st)
|
|
}
|
|
if st.stats != nil {
|
|
select {
|
|
case res := <-st.stats:
|
|
st.stats = nil
|
|
st.pending = st.pending[len(res):]
|
|
for _, s := range res {
|
|
switch {
|
|
case s.err != nil:
|
|
err := s.err
|
|
if pe, ok := errors.AsType[*fs.PathError](err); ok {
|
|
err = pe.Err
|
|
}
|
|
reject(s.path, "file_drop_zone.unreadable", err)
|
|
case s.dir:
|
|
reject(s.path, "file_drop_zone.folder")
|
|
case opts.MaxSize > 0 && s.size > opts.MaxSize:
|
|
reject(s.path, "file_drop_zone.size", fileDropZoneSize(opts.MaxSize))
|
|
case opts.MaxFiles > 0 && len(*files) >= opts.MaxFiles:
|
|
reject(s.path, "file_drop_zone.count", opts.MaxFiles)
|
|
default:
|
|
*files = append(slices.Clone(*files), DroppedFile{s.path, s.size})
|
|
parts.changed = true
|
|
}
|
|
}
|
|
if len(st.pending) > 0 {
|
|
fileDropZoneCheck(c, st)
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
if st.browse != nil {
|
|
select {
|
|
case r := <-st.browse:
|
|
st.browse = nil
|
|
if r.err != nil {
|
|
reject("", "file_picker.failed", r.err)
|
|
} else {
|
|
take(r.paths)
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
col.Children(func() {
|
|
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
|
|
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
|
disabled := zone.IsDisabled()
|
|
if dropped := zone.DroppedFiles(); dropped != nil && !disabled {
|
|
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
|
|
take(dropped)
|
|
}
|
|
over := zone.FileDragOver() && !disabled
|
|
bg, border := k.Surface, k.ControlBorder
|
|
if over {
|
|
bg, border = k.Selection, k.AccentText
|
|
}
|
|
zone.Background(bg).Draw(func(p *ui.Painter, r ui.Rect) {
|
|
p.StrokeDashed(r, border, theme.CardRadius, theme.BorderWidth*1.5)
|
|
})
|
|
hint := opts.Hint
|
|
if hint == "" {
|
|
hint = msg(c, "file_drop_zone.hint")
|
|
}
|
|
zone.Children(func() {
|
|
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
|
|
ui.Text(c, hint).TextAlign(ui.Center)
|
|
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
|
|
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
|
|
if b.Clicked() {
|
|
ch := make(chan filePickerResult, 1)
|
|
st.browse = ch
|
|
dlg := mygo.OpenDialogOptions{Multiple: opts.MaxFiles != 1}
|
|
if len(opts.Extensions) > 0 {
|
|
dlg.Filters = []mygo.FileFilter{{Name: hint, Extensions: opts.Extensions}}
|
|
}
|
|
go func() {
|
|
p, err := filePickerOpen(dlg)
|
|
ch <- filePickerResult{p, err}
|
|
c.Invalidate()
|
|
}()
|
|
}
|
|
})
|
|
fileDropZoneList(c, st, files, &parts, opts.Disabled)
|
|
})
|
|
return parts
|
|
}
|
|
|
|
// fileDropZoneCheck stats the pending files on a goroutine.
|
|
func fileDropZoneCheck(c *ui.Context, st *fileDropZoneState) {
|
|
ch := make(chan []fileDropZoneStat, 1)
|
|
st.stats = ch
|
|
paths := slices.Clone(st.pending)
|
|
go func() {
|
|
res := make([]fileDropZoneStat, len(paths))
|
|
for i, p := range paths {
|
|
info, err := os.Stat(p)
|
|
res[i] = fileDropZoneStat{path: p, err: err}
|
|
if err == nil {
|
|
res[i].size, res[i].dir = info.Size(), info.IsDir()
|
|
}
|
|
}
|
|
ch <- res
|
|
c.Invalidate()
|
|
}()
|
|
}
|
|
|
|
// fileDropZoneList shows accepted, checking and rejected files.
|
|
func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile, parts *FileDropZoneParts, disabled bool) {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
row := func(key string, icon string, color ui.Color, name, detail string, remove func()) {
|
|
ui.Row(c).Key(key).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
|
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
|
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
|
|
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
|
ui.Text(c, detail).TextColor(color).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Grow(1)
|
|
if remove != nil {
|
|
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
|
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
|
|
internal.Ring(x, k.Focus, theme.ControlRadius)
|
|
if x.Hovered() {
|
|
x.Background(k.SurfaceHover).TextColor(k.Text)
|
|
}
|
|
x.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14) })
|
|
if x.Clicked() {
|
|
remove()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
ui.Column(c).Children(func() {
|
|
for i, f := range *files {
|
|
row("ok"+f.Path, "file", k.TextMuted, filepath.Base(f.Path), fileDropZoneSize(f.Size), func() {
|
|
*files = slices.Delete(slices.Clone(*files), i, i+1)
|
|
parts.changed = true
|
|
slog.Debug("mujica: file drop zone removed", "count", len(*files))
|
|
})
|
|
}
|
|
for _, p := range st.pending {
|
|
row("pending"+p, "loader-circle", k.TextMuted, filepath.Base(p), msg(c, "file_drop_zone.checking"), nil)
|
|
}
|
|
for i, r := range st.rejected {
|
|
name := filepath.Base(r.path)
|
|
if r.path == "" {
|
|
name = msg(c, "file_drop_zone.dialog")
|
|
}
|
|
row(fmt.Sprint("bad", i, r.path), "circle-alert", k.Danger, name, r.reason, func() {
|
|
st.rejected = slices.Delete(slices.Clone(st.rejected), i, i+1)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// fileDropZoneRules describes the limits, such as "PDF, PNG · up to 3 files · 2 MB each".
|
|
func fileDropZoneRules(c *ui.Context, opts FileDropZoneOptions) string {
|
|
var rules []string
|
|
if len(opts.Extensions) > 0 {
|
|
rules = append(rules, strings.ToUpper(strings.Join(opts.Extensions, ", ")))
|
|
}
|
|
if opts.MaxFiles > 0 {
|
|
rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_files"), opts.MaxFiles))
|
|
}
|
|
if opts.MaxSize > 0 {
|
|
rules = append(rules, fmt.Sprintf(msg(c, "file_drop_zone.max_size"), fileDropZoneSize(opts.MaxSize)))
|
|
}
|
|
if len(rules) == 0 {
|
|
return msg(c, "file_drop_zone.any")
|
|
}
|
|
return strings.Join(rules, " · ")
|
|
}
|
|
|
|
// fileDropZoneSize formats n bytes in binary units.
|
|
func fileDropZoneSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
v, i := float64(n)/unit, 0
|
|
for v >= unit && i < 3 {
|
|
v /= unit
|
|
i++
|
|
}
|
|
return fmt.Sprintf("%.1f %s", v, []string{"KB", "MB", "GB", "TB"}[i])
|
|
}
|