mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-15 15:15:51 +01:00
* fix(v3): overhaul drag-and-drop for Linux reliability and simplify Windows This commit fixes drag-and-drop reliability on Linux and simplifies the Windows implementation. ## Linux - Rewrite GTK drag handlers to properly intercept external file drops - Fix HTML5 internal drag-and-drop being broken when file drop enabled - Add hover effects during file drag operations - Fix multiple app instances interfering with each other ## Windows - Remove native IDropTarget in favor of JavaScript approach (matches v2) - File drops now handled via chrome.webview.postMessageWithAdditionalObjects ## All Platforms - Rename EnableDragAndDrop to EnableFileDrop - Rename data-wails-drop-target to data-file-drop-target - Rename wails-drop-target-active to file-drop-target-active - Add comprehensive drag-and-drop documentation ## Breaking Changes - EnableDragAndDrop -> EnableFileDrop - data-wails-dropzone -> data-file-drop-target - wails-dropzone-hover -> file-drop-target-active - DropZoneDetails -> DropTargetDetails - Remove WindowDropZoneFilesDropped event (use WindowFilesDropped) * feat(macos): optimize drag event performance with debouncing and caching - Add 50ms debouncing to limit drag events to 20/sec (was 120/sec) - Implement window implementation caching to avoid repeated lookups - Maintain existing 5-pixel threshold for immediate response - Keep zero-allocation path with pre-allocated buffers - Rename linuxDragActive to nativeDragActive for clarity - Update IMPLEMENTATION.md with optimization details and Windows guidance Performance improvements: - 83% reduction in event frequency - ~6x reduction in CPU/memory usage during drag operations - Maintains smooth visual feedback with InvokeSync for timer callbacks * fix(windows): implement proper file drop support for Windows - Remove incorrect AllowExternalDrag(false) call that was blocking file drops - Fix message prefix from 'FilesDropped' to 'file:drop:' to match JS runtime - Fix coordinate parsing for 'file:drop:x:y' format (indices 2,3 not 1,2) - Add enableFileDrop flag injection to JS runtime during navigation - Update JS runtime to check enableFileDrop flag before processing drops - Always call preventDefault() to stop browser navigation on file drags - Show 'no drop' cursor when file drops are disabled - Update example to filter file drags from HTML drop zone handlers - Add documentation for combining file drop with HTML drag-and-drop * fix(v3): block file drops on Linux when EnableFileDrop is false - Add disableDND() to intercept and reject external file drags at GTK level - Show 'no drop' cursor when files are dragged over window - Allow internal HTML5 drag-and-drop to work normally - Initialize _wails.flags object in runtime core to prevent undefined errors - Inject enableFileDrop flag on Linux and macOS (matching Windows) - Fix bare _wails reference to use window._wails - Update docs with info about blocked drops and combining with HTML DnD * fix(darwin): add missing fmt import in webview_window_darwin.go * fix(macOS): implement hover effects for file drag-and-drop with optimizations - Added draggingUpdated: handler to track mouse movement during drag operations - Implemented macosOnDragEnter/Exit/Over export functions for real-time hover state - Fixed JS function call from '_wails.handlePlatformFileDrop' to correct 'wails.Window.HandlePlatformFileDrop' - Added EnableFileDrop flag checks to prevent hover effects when file drops are disabled - Renamed linuxDragActive to nativeDragActive for cross-platform consistency Performance optimizations: - Added 50ms debounce to reduce event frequency from ~120/sec to ~20/sec - Implemented 5-pixel movement threshold for immediate response - Added window caching with sync.Map to avoid repeated lookups - Zero-allocation JavaScript calls with pre-allocated 128-byte buffer - Reduced memory usage to ~18 bytes per event (6x reduction) Build improvements: - Updated runtime Taskfile to include documentation generation - Added docs:build task to runtime build process - Fixed build order: events → docs → runtime Documentation: - Added IMPLEMENTATION.md with optimization details - Included guidance for Windows implementation * chore(v3/examples): remove html-dnd-api example The drag-n-drop example now demonstrates both external file drops and internal HTML5 drag-and-drop, making this separate example redundant. * docs(v3): move drag-and-drop implementation details to runtime-internals - Add drag-and-drop section to contributing/runtime-internals.mdx - Remove IMPLEMENTATION.md from example (content now in proper docs) - Covers platform differences, debugging tips, and key files * fix(v3): remove html-dnd-api from example build list * fix(v3): remove duplicate json import in application_darwin.go * fix(v3): address CodeRabbit review feedback - Fix docs to use app.Window.NewWithOptions() instead of deprecated API - Add mutex protection to dragOverJSBuffer to prevent race conditions - Add mutex protection to dragThrottleState fields for thread safety * docs: add coderabbit pre-push requirement to AGENTS.md * fix(v3/test): use correct CSS class name file-drop-target-active * chore(v3/test): remove dnd-test directory This was a development test file that shouldn't be in the PR. The drag-n-drop example serves as the proper test case. * docs(v3): update Windows file drop comment to reflect implemented fix Remove stale TODO - enableFileDrop flag is now injected in navigationCompleted * refactor(v3): make handleDragAndDropMessage unexported Internal method only called by application event loop, not part of public API.
438 lines
11 KiB
Go
438 lines
11 KiB
Go
//go:build linux && !android
|
|
|
|
package application
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"unsafe"
|
|
|
|
"github.com/bep/debounce"
|
|
"github.com/wailsapp/wails/v3/internal/assetserver"
|
|
"github.com/wailsapp/wails/v3/internal/capabilities"
|
|
"github.com/wailsapp/wails/v3/internal/runtime"
|
|
"github.com/wailsapp/wails/v3/pkg/events"
|
|
)
|
|
|
|
type dragInfo struct {
|
|
XRoot int
|
|
YRoot int
|
|
DragTime uint32
|
|
MouseButton uint
|
|
}
|
|
|
|
type linuxWebviewWindow struct {
|
|
id uint
|
|
application pointer
|
|
window pointer
|
|
webview pointer
|
|
parent *WebviewWindow
|
|
menubar pointer
|
|
vbox pointer
|
|
accels pointer
|
|
lastWidth int
|
|
lastHeight int
|
|
drag dragInfo
|
|
lastX, lastY int
|
|
gtkmenu pointer
|
|
ctxMenuOpened bool
|
|
|
|
moveDebouncer func(func())
|
|
resizeDebouncer func(func())
|
|
ignoreMouseEvents bool
|
|
}
|
|
|
|
var (
|
|
registered bool = false // avoid 'already registered message' about 'wails://'
|
|
)
|
|
|
|
func (w *linuxWebviewWindow) endDrag(button uint, x, y int) {
|
|
w.drag.XRoot = 0.0
|
|
w.drag.YRoot = 0.0
|
|
w.drag.DragTime = 0
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) connectSignals() {
|
|
cb := func(e events.WindowEventType) {
|
|
w.parent.emit(e)
|
|
}
|
|
w.setupSignalHandlers(cb)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) openContextMenu(menu *Menu, data *ContextMenuData) {
|
|
// Create the menu manually because we don't want a gtk_menu_bar
|
|
// as the top-level item
|
|
ctxMenu := &linuxMenu{
|
|
menu: menu,
|
|
}
|
|
if menu.impl == nil {
|
|
ctxMenu.update()
|
|
|
|
native := ctxMenu.menu.impl.(*linuxMenu).native
|
|
w.contextMenuSignals(native)
|
|
}
|
|
|
|
native := ctxMenu.menu.impl.(*linuxMenu).native
|
|
w.contextMenuShow(native, data)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) focus() {
|
|
w.present()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) isNormal() bool {
|
|
return !w.isMinimised() && !w.isMaximised() && !w.isFullscreen()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setCloseButtonEnabled(enabled bool) {
|
|
// C.enableCloseButton(w.nsWindow, C.bool(enabled))
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setFullscreenButtonEnabled(enabled bool) {
|
|
// Not implemented
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMinimiseButtonEnabled(enabled bool) {
|
|
//C.enableMinimiseButton(w.nsWindow, C.bool(enabled))
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMaximiseButtonEnabled(enabled bool) {
|
|
//C.enableMaximiseButton(w.nsWindow, C.bool(enabled))
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) disableSizeConstraints() {
|
|
x, y, width, height, scaleFactor := w.getCurrentMonitorGeometry()
|
|
w.setMinMaxSize(x, y, width*scaleFactor, height*scaleFactor)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) unminimise() {
|
|
w.present()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) on(eventID uint) {
|
|
// TODO: Test register/unregister listener for linux events
|
|
//C.registerListener(C.uint(eventID))
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) zoom() {
|
|
w.zoomIn()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) windowZoom() {
|
|
w.zoom() // FIXME> This should be removed
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) forceReload() {
|
|
w.reload()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) center() {
|
|
x, y, width, height, _ := w.getCurrentMonitorGeometry()
|
|
if x == -1 && y == -1 && width == -1 && height == -1 {
|
|
return
|
|
}
|
|
windowWidth, windowHeight := w.size()
|
|
|
|
newX := ((width - windowWidth) / 2) + x
|
|
newY := ((height - windowHeight) / 2) + y
|
|
|
|
// Place the window at the center of the monitor
|
|
w.move(newX, newY)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) restore() {
|
|
// restore window to normal size
|
|
// FIXME: never called! - remove from webviewImpl interface
|
|
}
|
|
|
|
func newWindowImpl(parent *WebviewWindow) *linuxWebviewWindow {
|
|
// (*C.struct__GtkWidget)(m.native)
|
|
//var menubar *C.struct__GtkWidget
|
|
result := &linuxWebviewWindow{
|
|
application: getNativeApplication().application,
|
|
parent: parent,
|
|
// menubar: menubar,
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMinMaxSize(minWidth, minHeight, maxWidth, maxHeight int) {
|
|
// Get current screen for window
|
|
_, _, monitorwidth, monitorheight, _ := w.getCurrentMonitorGeometry()
|
|
if monitorwidth == -1 {
|
|
monitorwidth = 1920
|
|
}
|
|
if monitorheight == -1 {
|
|
monitorheight = 1080
|
|
}
|
|
if maxWidth == 0 {
|
|
maxWidth = monitorwidth
|
|
}
|
|
if maxHeight == 0 {
|
|
maxHeight = monitorheight
|
|
}
|
|
windowSetGeometryHints(w.window, minWidth, minHeight, maxWidth, maxHeight)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMinSize(width, height int) {
|
|
w.setMinMaxSize(width, height, w.parent.options.MaxWidth, w.parent.options.MaxHeight)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) getBorderSizes() *LRTB {
|
|
return &LRTB{}
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMaxSize(width, height int) {
|
|
w.setMinMaxSize(w.parent.options.MinWidth, w.parent.options.MinHeight, width, height)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setRelativePosition(x, y int) {
|
|
mx, my, _, _, _ := w.getCurrentMonitorGeometry()
|
|
w.move(x+mx, y+my)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) width() int {
|
|
width, _ := w.size()
|
|
return width
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) height() int {
|
|
_, height := w.size()
|
|
return height
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setPosition(x int, y int) {
|
|
// Set the window's absolute position
|
|
w.move(x, y)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) bounds() Rect {
|
|
// DOTO: do it in a single step + proper DPI scaling
|
|
x, y := w.position()
|
|
width, height := w.size()
|
|
|
|
return Rect{
|
|
X: x,
|
|
Y: y,
|
|
Width: width,
|
|
Height: height,
|
|
}
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setBounds(bounds Rect) {
|
|
// DOTO: do it in a single step + proper DPI scaling
|
|
w.move(bounds.X, bounds.Y)
|
|
w.setSize(bounds.Width, bounds.Height)
|
|
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) physicalBounds() Rect {
|
|
// TODO: proper DPI scaling
|
|
return w.bounds()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setPhysicalBounds(physicalBounds Rect) {
|
|
// TODO: proper DPI scaling
|
|
w.setBounds(physicalBounds)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setMenu(menu *Menu) {
|
|
if menu == nil {
|
|
w.gtkmenu = nil
|
|
return
|
|
}
|
|
w.parent.options.Linux.Menu = menu
|
|
w.gtkmenu = (menu.impl).(*linuxMenu).native
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) run() {
|
|
for eventId := range w.parent.eventListeners {
|
|
w.on(eventId)
|
|
}
|
|
|
|
if w.moveDebouncer == nil {
|
|
debounceMS := w.parent.options.Linux.WindowDidMoveDebounceMS
|
|
if debounceMS == 0 {
|
|
debounceMS = 50 // Default value
|
|
}
|
|
w.moveDebouncer = debounce.New(time.Duration(debounceMS) * time.Millisecond)
|
|
}
|
|
if w.resizeDebouncer == nil {
|
|
debounceMS := w.parent.options.Linux.WindowDidMoveDebounceMS
|
|
if debounceMS == 0 {
|
|
debounceMS = 50 // Default value
|
|
}
|
|
w.resizeDebouncer = debounce.New(time.Duration(debounceMS) * time.Millisecond)
|
|
}
|
|
|
|
// Register the capabilities
|
|
globalApplication.capabilities = capabilities.NewCapabilities()
|
|
|
|
app := getNativeApplication()
|
|
|
|
var menu = w.parent.options.Linux.Menu
|
|
if menu != nil {
|
|
InvokeSync(func() {
|
|
menu.Update()
|
|
})
|
|
w.gtkmenu = (menu.impl).(*linuxMenu).native
|
|
}
|
|
|
|
w.window, w.webview, w.vbox = windowNew(app.application, w.gtkmenu, w.parent.id, w.parent.options.Linux.WebviewGpuPolicy)
|
|
app.registerWindow(w.window, w.parent.id) // record our mapping
|
|
w.connectSignals()
|
|
if w.parent.options.EnableFileDrop {
|
|
w.enableDND()
|
|
} else {
|
|
w.disableDND()
|
|
}
|
|
w.setTitle(w.parent.options.Title)
|
|
w.setIcon(app.icon)
|
|
w.setAlwaysOnTop(w.parent.options.AlwaysOnTop)
|
|
w.setResizable(!w.parent.options.DisableResize)
|
|
// Set min/max size with defaults
|
|
// Default min: 1x1 (smallest possible)
|
|
// Default max: 0x0 (uses screen size)
|
|
minWidth := w.parent.options.MinWidth
|
|
if minWidth == 0 {
|
|
minWidth = 1
|
|
}
|
|
minHeight := w.parent.options.MinHeight
|
|
if minHeight == 0 {
|
|
minHeight = 1
|
|
}
|
|
maxWidth := w.parent.options.MaxWidth
|
|
maxHeight := w.parent.options.MaxHeight
|
|
|
|
w.setMinMaxSize(minWidth, minHeight, maxWidth, maxHeight)
|
|
w.setDefaultSize(w.parent.options.Width, w.parent.options.Height)
|
|
w.setSize(w.parent.options.Width, w.parent.options.Height)
|
|
w.setZoom(w.parent.options.Zoom)
|
|
if w.parent.options.BackgroundType != BackgroundTypeSolid {
|
|
w.setTransparent()
|
|
w.setBackgroundColour(w.parent.options.BackgroundColour)
|
|
}
|
|
|
|
w.setFrameless(w.parent.options.Frameless)
|
|
|
|
if w.parent.options.InitialPosition == WindowCentered {
|
|
w.center()
|
|
} else {
|
|
w.setPosition(w.parent.options.X, w.parent.options.Y)
|
|
}
|
|
|
|
switch w.parent.options.StartState {
|
|
case WindowStateMaximised:
|
|
w.maximise()
|
|
case WindowStateMinimised:
|
|
w.minimise()
|
|
case WindowStateFullscreen:
|
|
w.fullscreen()
|
|
case WindowStateNormal:
|
|
}
|
|
|
|
// Ignore mouse events if requested
|
|
w.setIgnoreMouseEvents(w.parent.options.IgnoreMouseEvents)
|
|
|
|
startURL, err := assetserver.GetStartURL(w.parent.options.URL)
|
|
if err != nil {
|
|
globalApplication.handleFatalError(err)
|
|
}
|
|
|
|
w.setURL(startURL)
|
|
w.parent.OnWindowEvent(events.Linux.WindowLoadFinished, func(_ *WindowEvent) {
|
|
InvokeAsync(func() {
|
|
if w.parent.options.JS != "" {
|
|
w.execJS(w.parent.options.JS)
|
|
}
|
|
if w.parent.options.CSS != "" {
|
|
js := fmt.Sprintf("(function() { var style = document.createElement('style'); style.appendChild(document.createTextNode('%s')); document.head.appendChild(style); })();", w.parent.options.CSS)
|
|
w.execJS(js)
|
|
}
|
|
})
|
|
})
|
|
|
|
w.parent.RegisterHook(events.Linux.WindowLoadFinished, func(e *WindowEvent) {
|
|
// Inject runtime core and EnableFileDrop flag together
|
|
js := runtime.Core(globalApplication.impl.GetFlags(globalApplication.options))
|
|
js += fmt.Sprintf("window._wails.flags.enableFileDrop=%v;", w.parent.options.EnableFileDrop)
|
|
w.execJS(js)
|
|
})
|
|
if w.parent.options.HTML != "" {
|
|
w.setHTML(w.parent.options.HTML)
|
|
}
|
|
if !w.parent.options.Hidden {
|
|
w.show()
|
|
if w.parent.options.InitialPosition == WindowCentered {
|
|
w.center()
|
|
} else {
|
|
w.setRelativePosition(w.parent.options.X, w.parent.options.Y)
|
|
}
|
|
}
|
|
if w.parent.options.DevToolsEnabled || globalApplication.isDebugMode {
|
|
w.enableDevTools()
|
|
if w.parent.options.OpenInspectorOnStartup {
|
|
w.openDevTools()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) startResize(border string) error {
|
|
// FIXME: what do we need to do here?
|
|
return nil
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) nativeWindow() unsafe.Pointer {
|
|
return unsafe.Pointer(w.window)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) print() error {
|
|
w.execJS("window.print();")
|
|
return nil
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) handleKeyEvent(acceleratorString string) {
|
|
// Parse acceleratorString
|
|
// accelerator, err := parseAccelerator(acceleratorString)
|
|
// if err != nil {
|
|
// globalApplication.error("unable to parse accelerator: %w", err)
|
|
// return
|
|
// }
|
|
w.parent.processKeyBinding(acceleratorString)
|
|
}
|
|
|
|
// SetMinimiseButtonState is unsupported on Linux
|
|
func (w *linuxWebviewWindow) setMinimiseButtonState(state ButtonState) {}
|
|
|
|
// SetMaximiseButtonState is unsupported on Linux
|
|
func (w *linuxWebviewWindow) setMaximiseButtonState(state ButtonState) {}
|
|
|
|
// SetCloseButtonState is unsupported on Linux
|
|
func (w *linuxWebviewWindow) setCloseButtonState(state ButtonState) {}
|
|
|
|
func (w *linuxWebviewWindow) isIgnoreMouseEvents() bool {
|
|
return w.ignoreMouseEvents
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) setIgnoreMouseEvents(ignore bool) {
|
|
w.ignoreMouse(w.ignoreMouseEvents)
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) show() {
|
|
// Linux implementation is robust - window shows immediately
|
|
// This is the preferred pattern that Windows should follow
|
|
w.windowShow()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) hide() {
|
|
// Save position before hiding (consistent with CGO implementation)
|
|
w.lastX, w.lastY = w.position()
|
|
w.windowHide()
|
|
}
|
|
|
|
func (w *linuxWebviewWindow) showMenuBar() {}
|
|
func (w *linuxWebviewWindow) hideMenuBar() {}
|
|
func (w *linuxWebviewWindow) toggleMenuBar() {}
|
|
func (w *linuxWebviewWindow) snapAssist() {} // No-op on Linux
|
|
func (w *linuxWebviewWindow) setContentProtection(enabled bool) {}
|