diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go index bdefae6c..7c2bf688 100644 --- a/stubs/wails/pkg/application/application.go +++ b/stubs/wails/pkg/application/application.go @@ -809,7 +809,6 @@ type App struct { Logger Logger Window WindowManager Menu MenuManager - SystemTray SystemTrayManager Dialog DialogManager Event EventManager Browser BrowserManager @@ -817,6 +816,7 @@ type App struct { ContextMenu ContextMenuManager Environment EnvironmentManager Screen ScreenManager + SystemTray SystemTrayManager KeyBinding KeyBindingManager } diff --git a/stubs/wails/pkg/application/application_test.go b/stubs/wails/pkg/application/application_test.go index f1c748ea..efd0192f 100644 --- a/stubs/wails/pkg/application/application_test.go +++ b/stubs/wails/pkg/application/application_test.go @@ -1,10 +1,13 @@ package application import ( + "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/wailsapp/wails/v3/pkg/events" ) var _ Window = (*WebviewWindow)(nil) @@ -384,11 +387,154 @@ func TestApplication_App_Bad(t *testing.T) { var app App assert.Zero(t, app.Logger) - assert.Zero(t, app.Window) - assert.Zero(t, app.Menu) + assert.Empty(t, app.Window.GetAll()) + assert.Nil(t, app.Menu.applicationMenu) } func TestApplication_App_Ugly(t *testing.T) { app := &App{} app.Quit() } + +func TestApplication_AppManagers_Good(t *testing.T) { + app := &App{} + + require.NotNil(t, &app.Window) + require.NotNil(t, &app.Menu) + require.NotNil(t, &app.Dialog) + require.NotNil(t, &app.Event) + require.NotNil(t, &app.Browser) + require.NotNil(t, &app.Clipboard) + require.NotNil(t, &app.ContextMenu) + require.NotNil(t, &app.Environment) + require.NotNil(t, &app.Screen) + require.NotNil(t, &app.SystemTray) + require.NotNil(t, &app.KeyBinding) + + assert.NotPanics(t, func() { + window := app.Window.NewWithOptions(WebviewWindowOptions{Name: "app-managers"}) + require.NotNil(t, window) + + menu := app.NewMenu() + app.Menu.SetApplicationMenu(menu) + assert.Same(t, menu, app.Menu.applicationMenu) + assert.NotNil(t, app.Dialog.Info()) + _, err := app.Dialog.ShowInfo("Done", "Saved") + assert.NoError(t, err) + + assert.NoError(t, app.Browser.OpenURL("https://example.com")) + assert.NoError(t, app.Browser.Open("https://example.com")) + + assert.True(t, app.Clipboard.SetText("copied")) + text, ok := app.Clipboard.Text() + assert.True(t, ok) + assert.Equal(t, "copied", text) + + contextMenu := app.ContextMenu.New() + app.ContextMenu.Add("main", contextMenu) + gotMenu, exists := app.ContextMenu.Get("main") + assert.True(t, exists) + assert.Same(t, contextMenu, gotMenu) + + env := newEnvironmentManager().Info() + assert.Equal(t, runtime.GOOS, env.OS) + assert.Equal(t, runtime.GOARCH, env.Arch) + assert.NoError(t, app.Environment.OpenFileManager("/tmp", false)) + assert.False(t, app.Environment.HasFocusFollowsMouse()) + + cancel := app.Event.Once("ready", func(*CustomEvent) {}) + require.NotNil(t, cancel) + assert.False(t, app.Event.Emit("ready")) + cancel() + + screen := &Screen{ + ID: "primary", + IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + Size: Size{Width: 1920, Height: 1080}, + } + assert.NoError(t, app.Screen.LayoutScreens([]*Screen{screen})) + assert.Same(t, screen, app.Screen.GetPrimary()) + assert.Same(t, screen, app.Screen.Primary()) + assert.Equal(t, Point{X: 5, Y: 6}, app.Screen.DipToPhysicalPoint(Point{X: 5, Y: 6})) + + triggered := 0 + app.KeyBinding.Register("CmdOrCtrl+K", func(Window) { triggered++ }) + assert.True(t, app.KeyBinding.Process("CmdOrCtrl+K", nil)) + assert.Equal(t, 1, triggered) + + tray := app.SystemTray.New() + assert.NotNil(t, tray) + }) +} + +func TestApplication_AppManagers_Bad(t *testing.T) { + app := &App{} + + assert.NotPanics(t, func() { + assert.Nil(t, app.Window.GetByID(1)) + app.Menu.SetApplicationMenu(nil) + + _, err := app.Dialog.ShowError() + assert.NoError(t, err) + + assert.NoError(t, app.Browser.Open("")) + text, ok := app.Clipboard.Text() + assert.False(t, ok) + assert.Empty(t, text) + + app.ContextMenu.Remove("missing") + _, exists := app.ContextMenu.Get("missing") + assert.False(t, exists) + + info := app.Environment.Info() + assert.Empty(t, info.OS) + assert.Empty(t, info.Arch) + assert.NoError(t, app.Environment.OpenFileManager("", false)) + + app.Event.Off("missing") + app.Event.Reset() + + assert.Nil(t, app.Screen.Primary()) + app.KeyBinding.Unregister("missing") + assert.False(t, app.KeyBinding.Process("missing", nil)) + }) +} + +func TestApplication_AppManagers_Ugly(t *testing.T) { + app := &App{} + + assert.NotPanics(t, func() { + assert.True(t, app.Clipboard.SetText("zero\x00byte")) + assert.NoError(t, app.Browser.Open("/tmp/\x00report.txt")) + + app.ContextMenu.Add("dup", app.ContextMenu.New()) + app.ContextMenu.Add("dup", app.ContextMenu.New()) + assert.Len(t, app.ContextMenu.GetAll(), 1) + + cancelHook := app.Event.RegisterApplicationEventHook(events.ApplicationEventType(9), func(event *ApplicationEvent) { + event.Cancel() + }) + cancelListener := app.Event.OnApplicationEvent(events.ApplicationEventType(9), func(*ApplicationEvent) { + t.Fatal("cancelled event should not reach listeners") + }) + app.Event.handleApplicationEvent(&ApplicationEvent{Id: 9}) + cancelListener() + cancelHook() + + screen := &Screen{ + ID: "primary", + IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 100, Height: 100}, + Size: Size{Width: 100, Height: 100}, + } + app.Screen.SetScreens([]*Screen{screen}) + assert.Same(t, screen, app.Screen.ScreenNearestDipPoint(Point{X: 50, Y: 50})) + assert.Same(t, screen, app.Screen.ScreenNearestDipRect(Rect{X: 10, Y: 10, Width: 5, Height: 5})) + + triggered := 0 + app.KeyBinding.Register("CmdOrCtrl+Shift+P", func(Window) { triggered++ }) + app.KeyBinding.handleWindowKeyEvent(&windowKeyEvent{acceleratorString: "CmdOrCtrl+Shift+P"}) + assert.Equal(t, 1, triggered) + }) +} diff --git a/stubs/wails/pkg/application/browser.go b/stubs/wails/pkg/application/browser.go new file mode 100644 index 00000000..4fa452e2 --- /dev/null +++ b/stubs/wails/pkg/application/browser.go @@ -0,0 +1,19 @@ +package application + +import "strings" + +func newBrowserManager() *BrowserManager { + return &BrowserManager{} +} + +func (bm *BrowserManager) Open(target string) error { + if bm == nil { + return nil + } + + if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") { + return bm.OpenURL(target) + } + + return bm.OpenFile(target) +} diff --git a/stubs/wails/pkg/application/clipboard.go b/stubs/wails/pkg/application/clipboard.go index a901ea42..e24b5984 100644 --- a/stubs/wails/pkg/application/clipboard.go +++ b/stubs/wails/pkg/application/clipboard.go @@ -13,6 +13,10 @@ type Clipboard struct { set bool } +func newClipboard() *Clipboard { + return &Clipboard{} +} + // SetText stores the given text in the in-memory clipboard. // // cb.SetText("copied content") @@ -50,6 +54,10 @@ type ClipboardManager struct { clipboard *Clipboard } +func newClipboardManager() *ClipboardManager { + return &ClipboardManager{} +} + // SetText sets text in the clipboard. // // manager.SetText("some text") @@ -73,12 +81,12 @@ func (cm *ClipboardManager) Text() (string, bool) { // getClipboard returns the clipboard instance, creating it if needed. func (cm *ClipboardManager) getClipboard() *Clipboard { if cm == nil { - return &Clipboard{} + return newClipboard() } cm.mu.Lock() defer cm.mu.Unlock() if cm.clipboard == nil { - cm.clipboard = &Clipboard{} + cm.clipboard = newClipboard() } return cm.clipboard } diff --git a/stubs/wails/pkg/application/context_menu.go b/stubs/wails/pkg/application/context_menu.go index 1ac45dea..107cd6e3 100644 --- a/stubs/wails/pkg/application/context_menu.go +++ b/stubs/wails/pkg/application/context_menu.go @@ -22,6 +22,12 @@ type ContextMenuManager struct { contextMenus map[string]*ContextMenu } +func newContextMenuManager() *ContextMenuManager { + return &ContextMenuManager{ + contextMenus: make(map[string]*ContextMenu), + } +} + // New creates a new context menu. // // menu := manager.New() diff --git a/stubs/wails/pkg/application/dialog.go b/stubs/wails/pkg/application/dialog.go index 83e340a0..e658ebe2 100644 --- a/stubs/wails/pkg/application/dialog.go +++ b/stubs/wails/pkg/application/dialog.go @@ -356,6 +356,10 @@ type DialogManager struct { mu sync.RWMutex } +func newDialogManager() *DialogManager { + return &DialogManager{} +} + // OpenFile creates an open-file dialog. // // dialog := manager.OpenFile() @@ -419,3 +423,32 @@ func (dm *DialogManager) Warning() *MessageDialog { func (dm *DialogManager) Error() *MessageDialog { return newMessageDialog(ErrorDialogType) } + +func (dm *DialogManager) ShowInfo(args ...string) (string, error) { + return dm.showDialog(dm.Info(), args...) +} + +func (dm *DialogManager) ShowQuestion(args ...string) (string, error) { + return dm.showDialog(dm.Question(), args...) +} + +func (dm *DialogManager) ShowWarning(args ...string) (string, error) { + return dm.showDialog(dm.Warning(), args...) +} + +func (dm *DialogManager) ShowError(args ...string) (string, error) { + return dm.showDialog(dm.Error(), args...) +} + +func (dm *DialogManager) showDialog(dialog *MessageDialog, args ...string) (string, error) { + if dialog == nil { + return "", nil + } + if len(args) > 0 { + dialog.SetTitle(args[0]) + } + if len(args) > 1 { + dialog.SetMessage(args[1]) + } + return dialog.Show() +} diff --git a/stubs/wails/pkg/application/environment.go b/stubs/wails/pkg/application/environment.go index fd3ab4b4..d7f41d88 100644 --- a/stubs/wails/pkg/application/environment.go +++ b/stubs/wails/pkg/application/environment.go @@ -1,6 +1,10 @@ package application -import "sync" +import ( + "path/filepath" + "runtime" + "sync" +) // EnvironmentInfo holds information about the host environment. // @@ -12,6 +16,7 @@ type EnvironmentInfo struct { Debug bool IsDarkMode bool AccentColour string + OSInfo any PlatformInfo map[string]any } @@ -27,6 +32,16 @@ type EnvironmentManager struct { operatingSystem string architecture string debugMode bool + osInfo any + platformInfo map[string]any +} + +func newEnvironmentManager() *EnvironmentManager { + return &EnvironmentManager{ + operatingSystem: runtime.GOOS, + architecture: runtime.GOARCH, + platformInfo: make(map[string]any), + } } // SetDarkMode sets the dark mode state used by IsDarkMode. @@ -75,11 +90,47 @@ func (em *EnvironmentManager) GetAccentColor() string { func (em *EnvironmentManager) Info() EnvironmentInfo { em.mu.RLock() defer em.mu.RUnlock() + var platformInfo map[string]any + if len(em.platformInfo) > 0 { + platformInfo = make(map[string]any, len(em.platformInfo)) + for key, value := range em.platformInfo { + platformInfo[key] = value + } + } return EnvironmentInfo{ OS: em.operatingSystem, Arch: em.architecture, Debug: em.debugMode, IsDarkMode: em.darkMode, AccentColour: em.accentColour, + OSInfo: em.osInfo, + PlatformInfo: platformInfo, } } + +func (em *EnvironmentManager) OpenFileManager(path string, selectFile bool) error { + if em == nil { + return nil + } + em.mu.Lock() + if em.platformInfo == nil { + em.platformInfo = make(map[string]any) + } + em.platformInfo["lastOpenFileManagerPath"] = filepath.Clean(path) + em.platformInfo["lastOpenFileManagerSelect"] = selectFile + em.mu.Unlock() + return nil +} + +func (em *EnvironmentManager) HasFocusFollowsMouse() bool { + if em == nil { + return false + } + em.mu.RLock() + defer em.mu.RUnlock() + if em.platformInfo == nil { + return false + } + ffm, _ := em.platformInfo["focusFollowsMouse"].(bool) + return ffm +} diff --git a/stubs/wails/pkg/application/event.go b/stubs/wails/pkg/application/event.go new file mode 100644 index 00000000..edd56e93 --- /dev/null +++ b/stubs/wails/pkg/application/event.go @@ -0,0 +1,162 @@ +package application + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +type applicationEventHook struct { + callback func(*ApplicationEvent) +} + +type eventHookRegistry struct { + mu sync.RWMutex + hooks map[uint][]*applicationEventHook +} + +var eventHookRegistries sync.Map + +func (em *EventManager) Once(name string, callback func(*CustomEvent)) func() { + listener := &customEventListener{callback: callback, counter: 1} + em.mu.Lock() + em.ensureMapsLocked() + em.customListeners[name] = append(em.customListeners[name], listener) + em.mu.Unlock() + + return func() { + em.mu.Lock() + defer em.mu.Unlock() + if em.customListeners == nil { + return + } + updated := em.customListeners[name][:0] + for _, existing := range em.customListeners[name] { + if existing != listener { + updated = append(updated, existing) + } + } + em.customListeners[name] = updated + } +} + +func (em *EventManager) EmitEvent(event *CustomEvent) bool { + if event == nil { + return false + } + + em.mu.Lock() + em.ensureMapsLocked() + listeners := append([]*customEventListener(nil), em.customListeners[event.Name]...) + remaining := em.customListeners[event.Name][:0] + for _, listener := range em.customListeners[event.Name] { + if listener.counter < 0 { + remaining = append(remaining, listener) + continue + } + listener.counter-- + if listener.counter > 0 { + remaining = append(remaining, listener) + } + } + em.customListeners[event.Name] = remaining + em.mu.Unlock() + + for _, listener := range listeners { + if event.IsCancelled() { + break + } + invokeCustomEventListener(listener, event) + } + + return event.IsCancelled() +} + +func (em *EventManager) Reset() { + em.mu.Lock() + if em.customListeners != nil { + clear(em.customListeners) + } + em.mu.Unlock() +} + +func (em *EventManager) RegisterApplicationEventHook(eventType events.ApplicationEventType, callback func(*ApplicationEvent)) func() { + registry := getEventHookRegistry(em) + hook := &applicationEventHook{callback: callback} + eventID := uint(eventType) + + registry.mu.Lock() + registry.hooks[eventID] = append(registry.hooks[eventID], hook) + registry.mu.Unlock() + + return func() { + registry.mu.Lock() + defer registry.mu.Unlock() + updated := registry.hooks[eventID][:0] + for _, existing := range registry.hooks[eventID] { + if existing != hook { + updated = append(updated, existing) + } + } + registry.hooks[eventID] = updated + } +} + +func (em *EventManager) handleApplicationEvent(event *ApplicationEvent) { + if em == nil || event == nil { + return + } + + registry := getEventHookRegistry(em) + registry.mu.RLock() + hooks := append([]*applicationEventHook(nil), registry.hooks[event.Id]...) + registry.mu.RUnlock() + + for _, hook := range hooks { + if event.IsCancelled() { + return + } + if hook == nil || hook.callback == nil { + continue + } + func() { + defer func() { + _ = recover() + }() + hook.callback(event) + }() + } + + em.mu.RLock() + listeners := append([]*applicationEventListener(nil), em.appListeners[event.Id]...) + em.mu.RUnlock() + for _, listener := range listeners { + if event.IsCancelled() { + return + } + if listener == nil || listener.callback == nil { + continue + } + func() { + defer func() { + _ = recover() + }() + listener.callback(event) + }() + } +} + +func getEventHookRegistry(em *EventManager) *eventHookRegistry { + if em == nil { + return &eventHookRegistry{hooks: make(map[uint][]*applicationEventHook)} + } + + registry, ok := eventHookRegistries.Load(em) + if ok { + return registry.(*eventHookRegistry) + } + + newRegistry := &eventHookRegistry{hooks: make(map[uint][]*applicationEventHook)} + actual, _ := eventHookRegistries.LoadOrStore(em, newRegistry) + return actual.(*eventHookRegistry) +} diff --git a/stubs/wails/pkg/application/key_binding.go b/stubs/wails/pkg/application/key_binding.go new file mode 100644 index 00000000..786ed535 --- /dev/null +++ b/stubs/wails/pkg/application/key_binding.go @@ -0,0 +1,25 @@ +package application + +func newKeyBindingManager() *KeyBindingManager { + return &KeyBindingManager{} +} + +type windowKeyEvent struct { + windowId uint + acceleratorString string +} + +func (m *KeyBindingManager) Register(accelerator string, callback func(window Window)) { + m.Add(accelerator, callback) +} + +func (m *KeyBindingManager) Unregister(accelerator string) { + m.Remove(accelerator) +} + +func (m *KeyBindingManager) handleWindowKeyEvent(event *windowKeyEvent) { + if event == nil { + return + } + m.Process(event.acceleratorString, nil) +} diff --git a/stubs/wails/pkg/application/screen.go b/stubs/wails/pkg/application/screen.go index 45fbe35c..42f1265a 100644 --- a/stubs/wails/pkg/application/screen.go +++ b/stubs/wails/pkg/application/screen.go @@ -2,6 +2,21 @@ package application import "sync" +type Alignment int +type OffsetReference int + +const ( + TOP Alignment = iota + RIGHT + BOTTOM + LEFT +) + +const ( + BEGIN OffsetReference = iota + END +) + // Screen describes a physical or logical display. // // primary := manager.GetPrimary() @@ -47,6 +62,14 @@ type Size struct { Height int } +type ScreenPlacement struct { + Screen *Screen + Parent *Screen + Alignment Alignment + Offset int + OffsetReference OffsetReference +} + // Origin returns the top-left corner of the rectangle. func (r Rect) Origin() Point { return Point{X: r.X, Y: r.Y} @@ -74,6 +97,43 @@ func (r Rect) RectSize() Size { return Size{Width: r.Width, Height: r.Height} } +func (r Rect) Size() Size { + return r.RectSize() +} + +func (s Screen) Origin() Point { + return Point{X: s.X, Y: s.Y} +} + +func (p ScreenPlacement) Apply() { + if p.Screen == nil || p.Parent == nil { + return + } + + x := p.Parent.X + y := p.Parent.Y + + switch p.Alignment { + case TOP: + x += p.Offset + y -= p.Screen.Size.Height + case RIGHT: + x += p.Parent.Size.Width + y += p.Offset + case BOTTOM: + x += p.Offset + y += p.Parent.Size.Height + case LEFT: + x -= p.Screen.Size.Width + y += p.Offset + } + + p.Screen.X = x + p.Screen.Y = y + p.Screen.Bounds.X = x + p.Screen.Bounds.Y = y +} + // ScreenManager tracks connected screens and the active screen. // // manager.SetScreens(detectedScreens) @@ -85,6 +145,10 @@ type ScreenManager struct { primary *Screen } +func newScreenManager() *ScreenManager { + return &ScreenManager{} +} + // SetScreens replaces the full list of known screens and recomputes primary. // // manager.SetScreens(platformDetectedScreens) @@ -159,3 +223,85 @@ func (m *ScreenManager) GetCurrent() *Screen { } return m.primary } + +func (m *ScreenManager) LayoutScreens(screens []*Screen) error { + m.SetScreens(screens) + return nil +} + +func (m *ScreenManager) All() []*Screen { + return m.GetAll() +} + +func (m *ScreenManager) Primary() *Screen { + return m.GetPrimary() +} + +func (m *ScreenManager) Current() *Screen { + return m.GetCurrent() +} + +func (m *ScreenManager) DipToPhysicalPoint(dipPoint Point) Point { + return dipPoint +} + +func (m *ScreenManager) PhysicalToDipPoint(physicalPoint Point) Point { + return physicalPoint +} + +func (m *ScreenManager) DipToPhysicalRect(dipRect Rect) Rect { + return dipRect +} + +func (m *ScreenManager) PhysicalToDipRect(physicalRect Rect) Rect { + return physicalRect +} + +func (m *ScreenManager) ScreenNearestPhysicalPoint(physicalPoint Point) *Screen { + return m.screenNearestPoint(physicalPoint) +} + +func (m *ScreenManager) ScreenNearestDipPoint(dipPoint Point) *Screen { + return m.screenNearestPoint(dipPoint) +} + +func (m *ScreenManager) ScreenNearestPhysicalRect(physicalRect Rect) *Screen { + return m.screenNearestRect(physicalRect) +} + +func (m *ScreenManager) ScreenNearestDipRect(dipRect Rect) *Screen { + return m.screenNearestRect(dipRect) +} + +func (m *ScreenManager) screenNearestPoint(point Point) *Screen { + if m == nil { + return nil + } + + m.mu.RLock() + defer m.mu.RUnlock() + + for _, screen := range m.screens { + if screen != nil && screen.Bounds.Contains(point) { + return screen + } + } + + if m.current != nil { + return m.current + } + if m.primary != nil { + return m.primary + } + if len(m.screens) > 0 { + return m.screens[0] + } + return nil +} + +func (m *ScreenManager) screenNearestRect(rect Rect) *Screen { + if rect.IsEmpty() { + return m.screenNearestPoint(Point{}) + } + return m.screenNearestPoint(rect.Origin()) +}