From 8c25fac860c5050b625c35d9d7d8827f2a7c29c3 Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Sat, 21 Jan 2023 23:34:20 +0900 Subject: [PATCH] ebiten: add AppendDroppedFiles Closes #1868 --- examples/dropfile/main.go | 94 +++++++++++++++++++ input.go | 12 ++- internal/glfw/callback_notwindows.go | 9 ++ internal/glfw/callback_windows.go | 9 ++ internal/glfw/glfw_notwindows.go | 5 + internal/glfw/glfw_windows.go | 8 ++ internal/glfw/type_notwindows.go | 1 + internal/glfw/type_windows.go | 1 + internal/ui/input.go | 16 +++- internal/ui/ui_glfw.go | 44 +++++++++ internal/ui/ui_js.go | 134 +++++++++++++++++++++++++++ run.go | 12 +++ 12 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 examples/dropfile/main.go diff --git a/examples/dropfile/main.go b/examples/dropfile/main.go new file mode 100644 index 000000000..b293aac51 --- /dev/null +++ b/examples/dropfile/main.go @@ -0,0 +1,94 @@ +// Copyright 2023 The Ebitengine Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "image/png" + "log" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" +) + +type Game struct { + images []*ebiten.Image +} + +func (g *Game) Update() error { + for _, f := range ebiten.AppendDroppedFiles(nil) { + // Calling Close is not mandatory, but it is sligtly good to save memory. + defer f.Close() + + fi, err := f.Stat() + if err != nil { + log.Printf("%v", err) + continue + } + log.Printf("Name: %s, Size: %d, IsDir: %t, ModTime: %v", fi.Name(), fi.Size(), fi.IsDir(), fi.ModTime()) + + img, err := png.Decode(f) + if err != nil { + log.Printf("decoding PNG failed: %v", err) + continue + } + g.images = append(g.images, ebiten.NewImageFromImage(img)) + } + + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + if len(g.images) == 0 { + ebitenutil.DebugPrint(screen, "Drop PNG files onto this window!") + return + } + + const imageSize = 128 + xcount := screen.Bounds().Dx() / imageSize + if xcount == 0 { + return + } + + for i, img := range g.images { + x := (i % xcount) * imageSize + y := (i / xcount) * imageSize + + s := imageSize / float64(img.Bounds().Dx()) + if sy := imageSize / float64(img.Bounds().Dy()); s > sy { + s = sy + } + if s > 1 { + s = 1 + } + + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(s, s) + op.GeoM.Translate(float64(x), float64(y)) + op.Filter = ebiten.FilterLinear + screen.DrawImage(img, op) + } +} + +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return outsideWidth, outsideHeight +} + +func main() { + ebiten.SetWindowResizable(true) + ebiten.SetWindowTitle("Dropping Files (Ebitengine Demo)") + if err := ebiten.RunGame(&Game{}); err != nil { + log.Fatal(err) + } +} diff --git a/input.go b/input.go index f9fa27d0d..858e2cb9c 100644 --- a/input.go +++ b/input.go @@ -15,6 +15,7 @@ package ebiten import ( + "io/fs" "sync" "github.com/hajimehoshi/ebiten/v2/internal/gamepad" @@ -22,7 +23,7 @@ import ( "github.com/hajimehoshi/ebiten/v2/internal/ui" ) -// AppendInputChars appends "printable" runes, read from the keyboard at the time update is called, to runes, +// AppendInputChars appends "printable" runes, read from the keyboard at the time Update is called, to runes, // and returns the extended buffer. // Giving a slice that already has enough capacity works efficiently. // @@ -40,7 +41,7 @@ func AppendInputChars(runes []rune) []rune { return theInputState.appendInputChars(runes) } -// InputChars return "printable" runes read from the keyboard at the time update is called. +// InputChars return "printable" runes read from the keyboard at the time Update is called. // // Deprecated: as of v2.2. Use AppendInputChars instead. func InputChars() []rune { @@ -474,6 +475,11 @@ func (i *inputState) touchPosition(id TouchID) (int, int) { func (i *inputState) windowBeingClosed() bool { i.m.Lock() defer i.m.Unlock() - return i.state.WindowBeingClosed } + +func (i *inputState) appendDroppedFiles(files []fs.File) []fs.File { + i.m.Lock() + defer i.m.Unlock() + return append(files, i.state.DroppedFiles...) +} diff --git a/internal/glfw/callback_notwindows.go b/internal/glfw/callback_notwindows.go index da822aba3..9486cd23b 100644 --- a/internal/glfw/callback_notwindows.go +++ b/internal/glfw/callback_notwindows.go @@ -38,6 +38,15 @@ func ToCloseCallback(cb func(window *Window)) CloseCallback { } } +func ToDropCallback(cb func(window *Window, names []string)) DropCallback { + if cb == nil { + return nil + } + return func(window *glfw.Window, names []string) { + cb(theWindows.get(window), names) + } +} + func ToFramebufferSizeCallback(cb func(window *Window, width int, height int)) FramebufferSizeCallback { if cb == nil { return nil diff --git a/internal/glfw/callback_windows.go b/internal/glfw/callback_windows.go index 60c4f57fa..eb4afb420 100644 --- a/internal/glfw/callback_windows.go +++ b/internal/glfw/callback_windows.go @@ -36,6 +36,15 @@ func ToCloseCallback(cb func(window *Window)) CloseCallback { } } +func ToDropCallback(cb func(window *Window, names []string)) DropCallback { + if cb == nil { + return nil + } + return func(window *goglfw.Window, names []string) { + cb((*Window)(window), names) + } +} + func ToFramebufferSizeCallback(cb func(window *Window, width int, height int)) FramebufferSizeCallback { if cb == nil { return nil diff --git a/internal/glfw/glfw_notwindows.go b/internal/glfw/glfw_notwindows.go index 1b5d3b946..39f3365ce 100644 --- a/internal/glfw/glfw_notwindows.go +++ b/internal/glfw/glfw_notwindows.go @@ -187,6 +187,11 @@ func (w *Window) SetCloseCallback(cbfun CloseCallback) (previous CloseCallback) return ToCloseCallback(nil) // TODO } +func (w *Window) SetDropCallback(cbfun DropCallback) (previous DropCallback) { + w.w.SetDropCallback(cbfun) + return ToDropCallback(nil) // TODO +} + func (w *Window) SetFramebufferSizeCallback(cbfun FramebufferSizeCallback) (previous FramebufferSizeCallback) { w.w.SetFramebufferSizeCallback(cbfun) return ToFramebufferSizeCallback(nil) // TODO diff --git a/internal/glfw/glfw_windows.go b/internal/glfw/glfw_windows.go index 129920e23..256d73ae1 100644 --- a/internal/glfw/glfw_windows.go +++ b/internal/glfw/glfw_windows.go @@ -184,6 +184,14 @@ func (w *Window) SetCloseCallback(cbfun CloseCallback) (previous CloseCallback) return f } +func (w *Window) SetDropCallback(cbfun DropCallback) (previous DropCallback) { + f, err := (*goglfw.Window)(w).SetDropCallback(cbfun) + if err != nil { + panic(err) + } + return f +} + func (w *Window) SetCursor(cursor *Cursor) { if err := (*goglfw.Window)(w).SetCursor((*goglfw.Cursor)(cursor)); err != nil { panic(err) diff --git a/internal/glfw/type_notwindows.go b/internal/glfw/type_notwindows.go index 8e254cd47..81b66d1ef 100644 --- a/internal/glfw/type_notwindows.go +++ b/internal/glfw/type_notwindows.go @@ -23,6 +23,7 @@ import ( type ( CharModsCallback = glfw.CharModsCallback CloseCallback = glfw.CloseCallback + DropCallback = glfw.DropCallback FramebufferSizeCallback = glfw.FramebufferSizeCallback MonitorCallback = glfw.MonitorCallback ScrollCallback = glfw.ScrollCallback diff --git a/internal/glfw/type_windows.go b/internal/glfw/type_windows.go index b8825fd2f..cba83e140 100644 --- a/internal/glfw/type_windows.go +++ b/internal/glfw/type_windows.go @@ -21,6 +21,7 @@ import ( type ( CharModsCallback = goglfw.CharModsCallback CloseCallback = goglfw.CloseCallback + DropCallback = goglfw.DropCallback FramebufferSizeCallback = goglfw.FramebufferSizeCallback MonitorCallback = goglfw.MonitorCallback ScrollCallback = goglfw.ScrollCallback diff --git a/internal/ui/input.go b/internal/ui/input.go index ef76cd65f..fce14f46d 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -15,6 +15,7 @@ package ui import ( + "io/fs" "unicode" ) @@ -47,6 +48,7 @@ type InputState struct { Touches []Touch Runes []rune WindowBeingClosed bool + DroppedFiles []fs.File } func (i *InputState) copyAndReset(dst *InputState) { @@ -59,14 +61,22 @@ func (i *InputState) copyAndReset(dst *InputState) { dst.Touches = append(dst.Touches[:0], i.Touches...) dst.Runes = append(dst.Runes[:0], i.Runes...) dst.WindowBeingClosed = i.WindowBeingClosed + for idx := range dst.DroppedFiles { + dst.DroppedFiles[idx] = nil + } + dst.DroppedFiles = append(dst.DroppedFiles[:0], i.DroppedFiles...) // Reset the members that are updated by deltas, rather than absolute values. i.WheelX = 0 i.WheelY = 0 i.Runes = i.Runes[:0] - // Reset the member that is never reset until it is explicitly done. + // Reset the members that are never reset until they are explicitly done. i.WindowBeingClosed = false + for idx := range i.DroppedFiles { + i.DroppedFiles[idx] = nil + } + i.DroppedFiles = i.DroppedFiles[:0] } func (i *InputState) appendRune(r rune) { @@ -75,3 +85,7 @@ func (i *InputState) appendRune(r rune) { } i.Runes = append(i.Runes, r) } + +func (i *InputState) appendDroppedFiles(files []fs.File) { + i.DroppedFiles = append(i.DroppedFiles, files...) +} diff --git a/internal/ui/ui_glfw.go b/internal/ui/ui_glfw.go index 0d4e6fc7c..854325ab6 100644 --- a/internal/ui/ui_glfw.go +++ b/internal/ui/ui_glfw.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "image" + "io/fs" "os" "runtime" "sync" @@ -105,6 +106,7 @@ type userInterfaceImpl struct { closeCallback glfw.CloseCallback framebufferSizeCallback glfw.FramebufferSizeCallback defaultFramebufferSizeCallback glfw.FramebufferSizeCallback + dropCallback glfw.DropCallback framebufferSizeCallbackCh chan struct{} glContextSetOnce sync.Once @@ -746,6 +748,30 @@ func (u *userInterfaceImpl) registerWindowFramebufferSizeCallback() { u.window.SetFramebufferSizeCallback(u.defaultFramebufferSizeCallback) } +func (u *userInterfaceImpl) registerDropCallback() { + if u.dropCallback == nil { + u.dropCallback = glfw.ToDropCallback(func(_ *glfw.Window, names []string) { + var files []fs.File + for _, name := range names { + f, err := os.Open(name) + if err != nil { + files = append(files, &errorFile{ + name: name, + err: err, + }) + continue + } + files = append(files, f) + } + + u.m.Lock() + defer u.m.Unlock() + u.inputState.appendDroppedFiles(files) + }) + } + u.window.SetDropCallback(u.dropCallback) +} + // waitForFramebufferSizeCallback waits for GLFW's FramebufferSize callback. // f is a process executed after registering the callback. // If the callback is not invoked for a while, waitForFramebufferSizeCallback times out and return. @@ -899,6 +925,7 @@ func (u *userInterfaceImpl) initOnMainThread(options *RunOptions) error { u.registerWindowCloseCallback() u.registerWindowFramebufferSizeCallback() u.registerInputCallbacks() + u.registerDropCallback() return nil } @@ -1517,3 +1544,20 @@ func (u *userInterfaceImpl) setOrigWindowPos(x, y int) { func IsScreenTransparentAvailable() bool { return true } + +type errorFile struct { + name string + err error +} + +func (e *errorFile) Stat() (fs.FileInfo, error) { + return nil, fmt.Errorf("ui: failed to open %s: %w", e.name, e.err) +} + +func (e *errorFile) Read(buf []byte) (int, error) { + return 0, fmt.Errorf("ui: failed to open %s: %w", e.name, e.err) +} + +func (e *errorFile) Close() error { + return nil +} diff --git a/internal/ui/ui_js.go b/internal/ui/ui_js.go index 76a635321..f17770172 100644 --- a/internal/ui/ui_js.go +++ b/internal/ui/ui_js.go @@ -15,6 +15,9 @@ package ui import ( + "fmt" + "io" + "io/fs" "sync" "syscall/js" "time" @@ -622,6 +625,27 @@ func setCanvasEventHandlers(v js.Value) { window.Get("location").Call("reload") return nil })) + + // Drop + v.Call("addEventListener", "dragover", js.FuncOf(func(this js.Value, args []js.Value) any { + e := args[0] + e.Call("preventDefault") + return nil + })) + v.Call("addEventListener", "drop", js.FuncOf(func(this js.Value, args []js.Value) any { + e := args[0] + e.Call("preventDefault") + data := e.Get("dataTransfer") + if !data.Truthy() { + return nil + } + + files := data.Get("files") + for i := 0; i < files.Length(); i++ { + theUI.inputState.DroppedFiles = append(theUI.inputState.DroppedFiles, &file{v: files.Index(i)}) + } + return nil + })) } func (u *userInterfaceImpl) forceUpdateOnMinimumFPSMode() { @@ -696,3 +720,113 @@ func (u *userInterfaceImpl) updateIconIfNeeded() { func IsScreenTransparentAvailable() bool { return true } + +type file struct { + v js.Value + cursor int64 + uint8Array js.Value +} + +func (f *file) Stat() (fs.FileInfo, error) { + return &fileInfo{v: f.v}, nil +} + +func (f *file) Read(buf []byte) (int, error) { + if !f.uint8Array.Truthy() { + chArrayBuffer := make(chan js.Value, 1) + cbThen := js.FuncOf(func(this js.Value, args []js.Value) any { + chArrayBuffer <- args[0] + return nil + }) + defer cbThen.Release() + + chError := make(chan js.Value, 1) + cbCatch := js.FuncOf(func(this js.Value, args []js.Value) any { + chError <- args[0] + return nil + }) + defer cbCatch.Release() + + f.v.Call("arrayBuffer").Call("then", cbThen).Call("catch", cbCatch) + select { + case ab := <-chArrayBuffer: + f.uint8Array = js.Global().Get("Uint8Array").New(ab) + case err := <-chError: + return 0, fmt.Errorf("%s", err.Call("toString").String()) + } + } + + size := int64(f.uint8Array.Get("byteLength").Float()) + if f.cursor >= size { + return 0, io.EOF + } + + if len(buf) == 0 { + return 0, nil + } + + slice := f.uint8Array.Call("subarray", f.cursor, f.cursor+int64(len(buf))) + n := slice.Get("byteLength").Int() + js.CopyBytesToGo(buf[:n], slice) + f.cursor += int64(n) + if f.cursor >= size { + return n, io.EOF + } + return n, nil +} + +func (f *file) Close() error { + return nil +} + +type fileInfo struct { + v js.Value + isDir bool + isDirOnce sync.Once +} + +func (f *fileInfo) Name() string { + return f.v.Get("name").String() +} + +func (f *fileInfo) Size() int64 { + return int64(f.v.Get("size").Float()) +} + +func (f *fileInfo) Mode() fs.FileMode { + return 0400 +} + +func (f *fileInfo) ModTime() time.Time { + return time.UnixMilli(int64(f.v.Get("lastModified").Float())) +} + +func (f *fileInfo) IsDir() bool { + f.isDirOnce.Do(func() { + ch := make(chan struct{}) + + cbThen := js.FuncOf(func(this js.Value, args []js.Value) any { + close(ch) + return nil + }) + defer cbThen.Release() + + cbCatch := js.FuncOf(func(this js.Value, args []js.Value) any { + // This is not a reliable way to check whether the file is a directory or not. + // https://developer.mozilla.org/en-US/docs/Web/API/File + // TODO: Should the file system API be used? + f.isDir = true + close(ch) + return nil + }) + defer cbCatch.Release() + + f.v.Call("arrayBuffer").Call("then", cbThen).Call("catch", cbCatch) + <-ch + }) + return f.isDir +} + +func (f *fileInfo) Sys() any { + return nil +} diff --git a/run.go b/run.go index aeeed81b5..4dbf68885 100644 --- a/run.go +++ b/run.go @@ -18,6 +18,7 @@ import ( "errors" "image" "image/color" + "io/fs" "sync/atomic" "github.com/hajimehoshi/ebiten/v2/internal/clock" @@ -664,3 +665,14 @@ func toUIRunOptions(options *RunGameOptions) *ui.RunOptions { SkipTaskbar: options.SkipTaskbar, } } + +// AppendDroppedFiles appends files, dropped at the time Update is called, to the given slice, +// and returns the extended buffer. +// Giving a slice that already has enough capacity works efficiently. +// +// AppendDroppedFiles works on desktops and browsers. +// +// AppendDroppedFiles is concurrent-safe. +func AppendDroppedFiles(files []fs.File) []fs.File { + return theInputState.appendDroppedFiles(files) +}