From a4c696ec01145349c60e90812876c77ec6f7a4c1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:48:27 +0000 Subject: [PATCH] Implement display service spec wrappers --- pkg/clipboard/service.go | 7 +- pkg/display/display.go | 652 +++++++++++++++++++++++++++++++++++- pkg/display/display_test.go | 213 +++++++++++- pkg/display/messages.go | 5 + pkg/systray/menu.go | 8 +- pkg/systray/tray.go | 20 +- 6 files changed, 884 insertions(+), 21 deletions(-) diff --git a/pkg/clipboard/service.go b/pkg/clipboard/service.go index f37d623..8f5a3dd 100644 --- a/pkg/clipboard/service.go +++ b/pkg/clipboard/service.go @@ -60,7 +60,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskSetText: return s.platform.SetText(t.Text), true, nil case TaskClear: - return s.platform.SetText(""), true, nil + _ = s.platform.SetText("") + if writer, ok := s.platform.(imageWriter); ok { + // Best-effort clear for image-aware clipboard backends. + _ = writer.SetImage(nil) + } + return true, true, nil case TaskSetImage: if writer, ok := s.platform.(imageWriter); ok { return writer.SetImage(t.Data), true, nil diff --git a/pkg/display/display.go b/pkg/display/display.go index 3bad064..4797195 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -613,6 +613,65 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { ScreenWidth: screenWidth, ScreenHeight: screenHeight, }) + case "screen:list": + result, handled, err = s.Core().QUERY(screen.QueryAll{}) + case "screen:get": + id, e := wsRequire(msg.Data, "id") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(screen.QueryByID{ID: id}) + case "screen:primary": + result, handled, err = s.Core().QUERY(screen.QueryPrimary{}) + case "screen:at-point": + x, _ := msg.Data["x"].(float64) + y, _ := msg.Data["y"].(float64) + result, handled, err = s.Core().QUERY(screen.QueryAtPoint{X: int(x), Y: int(y)}) + case "screen:work-areas": + result, handled, err = s.Core().QUERY(screen.QueryWorkAreas{}) + case "screen:for-window": + name, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + screenInfo, screenErr := s.GetScreenForWindow(name) + if screenErr != nil { + return nil, false, screenErr + } + result, handled, err = screenInfo, true, nil + case "clipboard:read": + result, handled, err = s.Core().QUERY(clipboard.QueryText{}) + case "clipboard:write": + text, e := wsRequire(msg.Data, "text") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(clipboard.TaskSetText{Text: text}) + case "clipboard:has": + textResult, textHandled, textErr := s.Core().QUERY(clipboard.QueryText{}) + if textErr != nil { + return nil, false, textErr + } + hasContent := false + if textHandled { + if content, ok := textResult.(clipboard.ClipboardContent); ok { + hasContent = content.HasContent + } + } + if !hasContent { + imageResult, imageHandled, imageErr := s.Core().QUERY(clipboard.QueryImage{}) + if imageErr != nil { + return nil, false, imageErr + } + if imageHandled { + if content, ok := imageResult.(clipboard.ClipboardImageContent); ok { + hasContent = content.HasContent + } + } + } + result, handled, err = hasContent, true, nil + case "clipboard:clear": + result, handled, err = s.Core().PERFORM(clipboard.TaskClear{}) case "clipboard:read-image": result, handled, err = s.Core().QUERY(clipboard.QueryImage{}) case "clipboard:write-image": @@ -625,6 +684,53 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { return nil, false, fmt.Errorf("ws: invalid base64 image data: %w", decodeErr) } result, handled, err = s.Core().PERFORM(clipboard.TaskSetImage{Data: decoded}) + case "notification:show": + var opts notification.NotificationOptions + encoded, _ := json.Marshal(msg.Data) + _ = json.Unmarshal(encoded, &opts) + result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: opts}) + case "notification:info": + title, e := wsRequire(msg.Data, "title") + if e != nil { + return nil, false, e + } + message, e := wsRequire(msg.Data, "message") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityInfo, + }}) + case "notification:warning": + title, e := wsRequire(msg.Data, "title") + if e != nil { + return nil, false, e + } + message, e := wsRequire(msg.Data, "message") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityWarning, + }}) + case "notification:error": + title, e := wsRequire(msg.Data, "title") + if e != nil { + return nil, false, e + } + message, e := wsRequire(msg.Data, "message") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityError, + }}) case "notification:with-actions": title, e := wsRequire(msg.Data, "title") if e != nil { @@ -679,6 +785,83 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { return nil, false, e } result, handled, err = s.Core().PERFORM(systray.TaskSetLabel{Label: label}) + case "tray:set-icon": + data, e := wsRequire(msg.Data, "data") + if e != nil { + return nil, false, e + } + decoded, decodeErr := base64.StdEncoding.DecodeString(data) + if decodeErr != nil { + return nil, false, fmt.Errorf("ws: invalid base64 tray icon data: %w", decodeErr) + } + result, handled, err = s.Core().PERFORM(systray.TaskSetTrayIcon{Data: decoded}) + case "tray:set-menu": + raw, ok := msg.Data["items"] + if !ok { + return nil, false, fmt.Errorf("ws: missing required field %q", "items") + } + encoded, _ := json.Marshal(raw) + var items []systray.TrayMenuItem + if err := json.Unmarshal(encoded, &items); err != nil { + return nil, false, fmt.Errorf("ws: invalid tray menu items: %w", err) + } + result, handled, err = s.Core().PERFORM(systray.TaskSetTrayMenu{Items: items}) + case "tray:info": + result, handled, err = s.GetTrayInfo(), true, nil + case "theme:get": + result, handled, err = s.GetTheme(), true, nil + case "theme:system": + result, handled, err = s.GetSystemTheme(), true, nil + case "theme:set": + isDark, _ := msg.Data["isDark"].(bool) + result, handled, err = nil, true, s.SetTheme(isDark) + case "dialog:open-file": + var opts dialog.OpenFileOptions + encoded, _ := json.Marshal(msg.Data) + if err := json.Unmarshal(encoded, &opts); err != nil { + return nil, false, fmt.Errorf("ws: invalid open file options: %w", err) + } + paths, openErr := s.OpenFileDialog(opts) + if openErr != nil { + return nil, false, openErr + } + result, handled, err = paths, true, nil + case "dialog:save-file": + var opts dialog.SaveFileOptions + encoded, _ := json.Marshal(msg.Data) + if err := json.Unmarshal(encoded, &opts); err != nil { + return nil, false, fmt.Errorf("ws: invalid save file options: %w", err) + } + path, saveErr := s.SaveFileDialog(opts) + if saveErr != nil { + return nil, false, saveErr + } + result, handled, err = path, true, nil + case "dialog:open-directory": + var opts dialog.OpenDirectoryOptions + encoded, _ := json.Marshal(msg.Data) + if err := json.Unmarshal(encoded, &opts); err != nil { + return nil, false, fmt.Errorf("ws: invalid open directory options: %w", err) + } + path, dirErr := s.OpenDirectoryDialog(opts) + if dirErr != nil { + return nil, false, dirErr + } + result, handled, err = path, true, nil + case "dialog:confirm": + title, e := wsRequire(msg.Data, "title") + if e != nil { + return nil, false, e + } + message, e := wsRequire(msg.Data, "message") + if e != nil { + return nil, false, e + } + confirmed, confirmErr := s.ConfirmDialog(title, message) + if confirmErr != nil { + return nil, false, confirmErr + } + result, handled, err = confirmed, true, nil case "dialog:prompt": title, e := wsRequire(msg.Data, "title") if e != nil { @@ -688,14 +871,12 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { if e != nil { return nil, false, e } - result, handled, err = s.Core().PERFORM(dialog.TaskMessageDialog{ - Opts: dialog.MessageDialogOptions{ - Type: dialog.DialogInfo, - Title: title, - Message: message, - Buttons: []string{"OK", "Cancel"}, - }, - }) + button, accepted, promptErr := s.PromptDialog(title, message) + if promptErr != nil { + return nil, false, promptErr + } + _ = accepted + result, handled, err = button, true, nil default: return nil, false, nil } @@ -1209,6 +1390,461 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight) } +// --- Screen management --- + +// GetScreens returns all known screens. +func (s *Service) GetScreens() []screen.Screen { + result, handled, _ := s.Core().QUERY(screen.QueryAll{}) + if !handled { + return nil + } + screens, _ := result.([]screen.Screen) + return screens +} + +// GetScreen returns a screen by ID. +func (s *Service) GetScreen(id string) (*screen.Screen, error) { + result, handled, err := s.Core().QUERY(screen.QueryByID{ID: id}) + if err != nil { + return nil, err + } + if !handled { + return nil, fmt.Errorf("screen service not available") + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +// GetPrimaryScreen returns the primary screen. +func (s *Service) GetPrimaryScreen() (*screen.Screen, error) { + result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) + if err != nil { + return nil, err + } + if !handled { + return nil, fmt.Errorf("screen service not available") + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +// GetScreenAtPoint returns the screen containing the specified point. +func (s *Service) GetScreenAtPoint(x, y int) (*screen.Screen, error) { + result, handled, err := s.Core().QUERY(screen.QueryAtPoint{X: x, Y: y}) + if err != nil { + return nil, err + } + if !handled { + return nil, fmt.Errorf("screen service not available") + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +// GetScreenForWindow returns the screen containing the named window. +func (s *Service) GetScreenForWindow(name string) (*screen.Screen, error) { + info, err := s.GetWindowInfo(name) + if err != nil { + return nil, err + } + if info == nil { + return nil, nil + } + x := info.X + y := info.Y + if info.Width > 0 && info.Height > 0 { + x += info.Width / 2 + y += info.Height / 2 + } + return s.GetScreenAtPoint(x, y) +} + +// GetWorkAreas returns the usable area of every screen. +func (s *Service) GetWorkAreas() []screen.Rect { + result, handled, _ := s.Core().QUERY(screen.QueryWorkAreas{}) + if !handled { + return nil + } + areas, _ := result.([]screen.Rect) + return areas +} + +// --- Clipboard --- + +// ReadClipboard returns the current clipboard text content. +func (s *Service) ReadClipboard() (string, error) { + result, handled, err := s.Core().QUERY(clipboard.QueryText{}) + if err != nil { + return "", err + } + if !handled { + return "", fmt.Errorf("clipboard service not available") + } + content, _ := result.(clipboard.ClipboardContent) + return content.Text, nil +} + +// WriteClipboard writes text to the clipboard. +func (s *Service) WriteClipboard(text string) error { + result, handled, err := s.Core().PERFORM(clipboard.TaskSetText{Text: text}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("clipboard service not available") + } + if ok, _ := result.(bool); !ok { + return fmt.Errorf("clipboard write failed") + } + return nil +} + +// HasClipboard reports whether the clipboard has text or image content. +func (s *Service) HasClipboard() bool { + textResult, textHandled, _ := s.Core().QUERY(clipboard.QueryText{}) + if textHandled { + if content, ok := textResult.(clipboard.ClipboardContent); ok && content.HasContent { + return true + } + } + imageResult, imageHandled, _ := s.Core().QUERY(clipboard.QueryImage{}) + if imageHandled { + if content, ok := imageResult.(clipboard.ClipboardImageContent); ok && content.HasContent { + return true + } + } + return false +} + +// ClearClipboard clears clipboard text and any image data when supported. +func (s *Service) ClearClipboard() error { + result, handled, err := s.Core().PERFORM(clipboard.TaskClear{}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("clipboard service not available") + } + if ok, _ := result.(bool); !ok { + return fmt.Errorf("clipboard clear failed") + } + return nil +} + +// ReadClipboardImage returns the clipboard image content. +func (s *Service) ReadClipboardImage() (clipboard.ClipboardImageContent, error) { + result, handled, err := s.Core().QUERY(clipboard.QueryImage{}) + if err != nil { + return clipboard.ClipboardImageContent{}, err + } + if !handled { + return clipboard.ClipboardImageContent{}, fmt.Errorf("clipboard service not available") + } + content, _ := result.(clipboard.ClipboardImageContent) + return content, nil +} + +// WriteClipboardImage writes raw image data to the clipboard. +func (s *Service) WriteClipboardImage(data []byte) error { + result, handled, err := s.Core().PERFORM(clipboard.TaskSetImage{Data: data}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("clipboard service not available") + } + if ok, _ := result.(bool); !ok { + return fmt.Errorf("clipboard image write failed") + } + return nil +} + +// --- Notifications --- + +// ShowNotification sends a native notification. +func (s *Service) ShowNotification(opts notification.NotificationOptions) error { + _, handled, err := s.Core().PERFORM(notification.TaskSend{Opts: opts}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("notification service not available") + } + return nil +} + +// ShowInfoNotification sends an informational notification. +func (s *Service) ShowInfoNotification(title, message string) error { + return s.ShowNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityInfo, + }) +} + +// ShowWarningNotification sends a warning notification. +func (s *Service) ShowWarningNotification(title, message string) error { + return s.ShowNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityWarning, + }) +} + +// ShowErrorNotification sends an error notification. +func (s *Service) ShowErrorNotification(title, message string) error { + return s.ShowNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityError, + }) +} + +// RequestNotificationPermission requests notification permission. +func (s *Service) RequestNotificationPermission() (bool, error) { + result, handled, err := s.Core().PERFORM(notification.TaskRequestPermission{}) + if err != nil { + return false, err + } + if !handled { + return false, fmt.Errorf("notification service not available") + } + granted, _ := result.(bool) + return granted, nil +} + +// CheckNotificationPermission checks notification permission. +func (s *Service) CheckNotificationPermission() (bool, error) { + result, handled, err := s.Core().QUERY(notification.QueryPermission{}) + if err != nil { + return false, err + } + if !handled { + return false, fmt.Errorf("notification service not available") + } + status, _ := result.(notification.PermissionStatus) + return status.Granted, nil +} + +// ClearNotifications clears notifications when supported. +func (s *Service) ClearNotifications() error { + _, handled, err := s.Core().PERFORM(notification.TaskClear{}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("notification service not available") + } + return nil +} + +// --- Dialogs --- + +// OpenFileDialog opens a file picker and returns all selected paths. +func (s *Service) OpenFileDialog(opts dialog.OpenFileOptions) ([]string, error) { + result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{Opts: opts}) + if err != nil { + return nil, err + } + if !handled { + return nil, fmt.Errorf("dialog service not available") + } + paths, _ := result.([]string) + return paths, nil +} + +// OpenSingleFileDialog opens a file picker and returns the first selected path. +func (s *Service) OpenSingleFileDialog(opts dialog.OpenFileOptions) (string, error) { + paths, err := s.OpenFileDialog(opts) + if err != nil { + return "", err + } + if len(paths) == 0 { + return "", nil + } + return paths[0], nil +} + +// SaveFileDialog opens a save dialog and returns the selected path. +func (s *Service) SaveFileDialog(opts dialog.SaveFileOptions) (string, error) { + result, handled, err := s.Core().PERFORM(dialog.TaskSaveFile{Opts: opts}) + if err != nil { + return "", err + } + if !handled { + return "", fmt.Errorf("dialog service not available") + } + path, _ := result.(string) + return path, nil +} + +// OpenDirectoryDialog opens a directory picker and returns the selected path. +func (s *Service) OpenDirectoryDialog(opts dialog.OpenDirectoryOptions) (string, error) { + result, handled, err := s.Core().PERFORM(dialog.TaskOpenDirectory{Opts: opts}) + if err != nil { + return "", err + } + if !handled { + return "", fmt.Errorf("dialog service not available") + } + path, _ := result.(string) + return path, nil +} + +// ConfirmDialog shows a confirmation prompt. +func (s *Service) ConfirmDialog(title, message string) (bool, error) { + result, handled, err := s.Core().PERFORM(dialog.TaskMessageDialog{ + Opts: dialog.MessageDialogOptions{ + Type: dialog.DialogQuestion, + Title: title, + Message: message, + Buttons: []string{"Yes", "No"}, + }, + }) + if err != nil { + return false, err + } + if !handled { + return false, fmt.Errorf("dialog service not available") + } + button, _ := result.(string) + return button == "Yes" || button == "OK", nil +} + +// PromptDialog shows a prompt-style dialog and returns the selected button. +func (s *Service) PromptDialog(title, message string) (string, bool, error) { + result, handled, err := s.Core().PERFORM(dialog.TaskMessageDialog{ + Opts: dialog.MessageDialogOptions{ + Type: dialog.DialogInfo, + Title: title, + Message: message, + Buttons: []string{"OK", "Cancel"}, + }, + }) + if err != nil { + return "", false, err + } + if !handled { + return "", false, fmt.Errorf("dialog service not available") + } + button, _ := result.(string) + return button, button == "OK", nil +} + +// --- Theme --- + +// GetTheme returns the current theme state. +func (s *Service) GetTheme() *Theme { + result, handled, err := s.Core().QUERY(environment.QueryTheme{}) + if err != nil || !handled { + return nil + } + theme, ok := result.(environment.ThemeInfo) + if !ok { + return nil + } + return &Theme{IsDark: theme.IsDark} +} + +// GetSystemTheme returns the current system theme preference. +func (s *Service) GetSystemTheme() string { + result, handled, err := s.Core().QUERY(environment.QueryTheme{}) + if err != nil || !handled { + return "" + } + theme, ok := result.(environment.ThemeInfo) + if !ok { + return "" + } + if theme.IsDark { + return "dark" + } + return "light" +} + +// SetTheme overrides the application theme. +func (s *Service) SetTheme(isDark bool) error { + _, handled, err := s.Core().PERFORM(environment.TaskSetTheme{IsDark: isDark}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("environment service not available") + } + return nil +} + +// --- Tray --- + +// SetTrayIcon sets the tray icon image. +func (s *Service) SetTrayIcon(data []byte) error { + _, handled, err := s.Core().PERFORM(systray.TaskSetTrayIcon{Data: data}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("systray service not available") + } + return nil +} + +// SetTrayTooltip updates the tray tooltip. +func (s *Service) SetTrayTooltip(tooltip string) error { + _, handled, err := s.Core().PERFORM(systray.TaskSetTooltip{Tooltip: tooltip}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("systray service not available") + } + return nil +} + +// SetTrayLabel updates the tray label. +func (s *Service) SetTrayLabel(label string) error { + _, handled, err := s.Core().PERFORM(systray.TaskSetLabel{Label: label}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("systray service not available") + } + return nil +} + +// SetTrayMenu replaces the tray menu items. +func (s *Service) SetTrayMenu(items []systray.TrayMenuItem) error { + _, handled, err := s.Core().PERFORM(systray.TaskSetTrayMenu{Items: items}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("systray service not available") + } + return nil +} + +// GetTrayInfo returns current tray state information. +func (s *Service) GetTrayInfo() map[string]any { + svc, err := core.ServiceFor[*systray.Service](s.Core(), "systray") + if err != nil || svc == nil || svc.Manager() == nil { + return nil + } + return svc.Manager().GetInfo() +} + +// ShowTrayMessage shows a tray message or notification. +func (s *Service) ShowTrayMessage(title, message string) error { + _, handled, err := s.Core().PERFORM(systray.TaskShowMessage{Title: title, Message: message}) + if err != nil { + return err + } + if !handled { + return fmt.Errorf("systray service not available") + } + return nil +} + // GetEventManager returns the event manager for WebSocket event subscriptions. func (s *Service) GetEventManager() *WSEventManager { return s.events diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index bee4377..6932525 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -10,6 +10,7 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/dialog" + "forge.lthn.ai/core/gui/pkg/environment" "forge.lthn.ai/core/gui/pkg/menu" "forge.lthn.ai/core/gui/pkg/notification" "forge.lthn.ai/core/gui/pkg/screen" @@ -79,15 +80,53 @@ func (m *mockNotificationPlatform) Clear() error { return nil } -type mockDialogPlatform struct { - button string - last dialog.MessageDialogOptions +type mockEnvironmentPlatform struct { + isDark bool + info environment.EnvironmentInfo + accent string } -func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { return nil, nil } -func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { return "", nil } +func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo { + return m.info +} +func (m *mockEnvironmentPlatform) AccentColour() string { return m.accent } +func (m *mockEnvironmentPlatform) OpenFileManager(path string, selectFile bool) error { + return nil +} +func (m *mockEnvironmentPlatform) OnThemeChange(handler func(isDark bool)) func() { + return func() {} +} +func (m *mockEnvironmentPlatform) SetTheme(isDark bool) error { + m.isDark = isDark + return nil +} + +type mockDialogPlatform struct { + button string + openFilePaths []string + saveFilePath string + openDirPath string + last dialog.MessageDialogOptions +} + +func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { + if len(m.openFilePaths) == 0 { + return []string{"/tmp/file.txt"}, nil + } + return m.openFilePaths, nil +} +func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { + if m.saveFilePath == "" { + return "/tmp/save.txt", nil + } + return m.saveFilePath, nil +} func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) { - return "", nil + if m.openDirPath == "" { + return "/tmp/dir", nil + } + return m.openDirPath, nil } func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) { m.last = opts @@ -163,10 +202,32 @@ func newTestConclave(t *testing.T) *core.Core { } func newExtendedTestConclave(t *testing.T) *core.Core { + t.Helper() + fixture := newExtendedTestConclaveWithMocks(t) + return fixture.core +} + +type extendedTestConclave struct { + core *core.Core + clipboardPlatform *mockClipboardPlatform + notificationPlatform *mockNotificationPlatform + dialogPlatform *mockDialogPlatform + environmentPlatform *mockEnvironmentPlatform +} + +func newExtendedTestConclaveWithMocks(t *testing.T) *extendedTestConclave { t.Helper() clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true} notificationPlatform := &mockNotificationPlatform{permGranted: true} dialogPlatform := &mockDialogPlatform{button: "OK"} + environmentPlatform := &mockEnvironmentPlatform{ + isDark: true, + accent: "rgb(0,122,255)", + info: environment.EnvironmentInfo{ + OS: "darwin", Arch: "arm64", + Platform: environment.PlatformInfo{Name: "macOS", Version: "14.0"}, + }, + } c, err := core.New( core.WithService(Register(nil)), @@ -184,12 +245,19 @@ func newExtendedTestConclave(t *testing.T) *core.Core { core.WithService(clipboard.Register(clipboardPlatform)), core.WithService(notification.Register(notificationPlatform)), core.WithService(dialog.Register(dialogPlatform)), + core.WithService(environment.Register(environmentPlatform)), core.WithService(webview.Register()), core.WithServiceLock(), ) require.NoError(t, err) require.NoError(t, c.ServiceStartup(context.Background(), nil)) - return c + return &extendedTestConclave{ + core: c, + clipboardPlatform: clipboardPlatform, + notificationPlatform: notificationPlatform, + dialogPlatform: dialogPlatform, + environmentPlatform: environmentPlatform, + } } // --- Tests --- @@ -574,6 +642,137 @@ func TestGetSavedWindowStates_Good(t *testing.T) { assert.NotNil(t, states) } +func TestServiceWrappers_Good(t *testing.T) { + fixture := newExtendedTestConclaveWithMocks(t) + svc := core.MustServiceFor[*Service](fixture.core, "display") + + t.Run("screen wrappers", func(t *testing.T) { + screens := svc.GetScreens() + require.Len(t, screens, 1) + + primary, err := svc.GetPrimaryScreen() + require.NoError(t, err) + require.NotNil(t, primary) + assert.True(t, primary.IsPrimary) + + atPoint, err := svc.GetScreenAtPoint(10, 10) + require.NoError(t, err) + require.NotNil(t, atPoint) + + workAreas := svc.GetWorkAreas() + require.Len(t, workAreas, 1) + }) + + t.Run("window-screen lookup", func(t *testing.T) { + require.NoError(t, svc.OpenWindow(window.WithName("screen-win"), window.WithSize(640, 480))) + screenInfo, err := svc.GetScreenForWindow("screen-win") + require.NoError(t, err) + require.NotNil(t, screenInfo) + }) + + t.Run("clipboard wrappers", func(t *testing.T) { + text, err := svc.ReadClipboard() + require.NoError(t, err) + assert.Equal(t, "hello", text) + assert.True(t, svc.HasClipboard()) + + require.NoError(t, svc.WriteClipboard("updated")) + text, err = svc.ReadClipboard() + require.NoError(t, err) + assert.Equal(t, "updated", text) + + image, err := svc.ReadClipboardImage() + require.NoError(t, err) + assert.True(t, image.HasContent) + + require.NoError(t, svc.WriteClipboardImage([]byte{9, 8, 7})) + require.NoError(t, svc.ClearClipboard()) + assert.False(t, svc.HasClipboard()) + }) + + t.Run("notification wrappers", func(t *testing.T) { + require.NoError(t, svc.ShowInfoNotification("Info", "Hello")) + require.True(t, fixture.notificationPlatform.sendCalled) + assert.Equal(t, notification.SeverityInfo, fixture.notificationPlatform.lastOpts.Severity) + + granted, err := svc.RequestNotificationPermission() + require.NoError(t, err) + assert.True(t, granted) + + granted, err = svc.CheckNotificationPermission() + require.NoError(t, err) + assert.True(t, granted) + + require.NoError(t, svc.ClearNotifications()) + assert.True(t, fixture.notificationPlatform.clearCalled) + }) + + t.Run("dialog wrappers", func(t *testing.T) { + paths, err := svc.OpenFileDialog(dialog.OpenFileOptions{Title: "Pick"}) + require.NoError(t, err) + require.NotEmpty(t, paths) + + path, err := svc.OpenSingleFileDialog(dialog.OpenFileOptions{Title: "Pick"}) + require.NoError(t, err) + assert.Equal(t, paths[0], path) + + path, err = svc.SaveFileDialog(dialog.SaveFileOptions{Filename: "out.txt"}) + require.NoError(t, err) + assert.Equal(t, "/tmp/save.txt", path) + + path, err = svc.OpenDirectoryDialog(dialog.OpenDirectoryOptions{Title: "Pick Dir"}) + require.NoError(t, err) + assert.Equal(t, "/tmp/dir", path) + + confirmed, err := svc.ConfirmDialog("Confirm", "Continue?") + require.NoError(t, err) + assert.True(t, confirmed) + + button, accepted, err := svc.PromptDialog("Question", "Continue?") + require.NoError(t, err) + assert.Equal(t, "OK", button) + assert.True(t, accepted) + }) + + t.Run("theme wrappers", func(t *testing.T) { + theme := svc.GetTheme() + require.NotNil(t, theme) + assert.True(t, theme.IsDark) + assert.Equal(t, "dark", svc.GetSystemTheme()) + + require.NoError(t, svc.SetTheme(false)) + assert.False(t, fixture.environmentPlatform.isDark) + theme = svc.GetTheme() + require.NotNil(t, theme) + assert.False(t, theme.IsDark) + assert.Equal(t, "light", svc.GetSystemTheme()) + }) + + t.Run("tray wrappers", func(t *testing.T) { + info := svc.GetTrayInfo() + require.NotNil(t, info) + assert.True(t, info["active"].(bool)) + + require.NoError(t, svc.SetTrayTooltip("Updated Tooltip")) + require.NoError(t, svc.SetTrayLabel("Updated Label")) + require.NoError(t, svc.SetTrayIcon([]byte{1, 2, 3})) + require.NoError(t, svc.SetTrayMenu([]systray.TrayMenuItem{ + {Label: "One", ActionID: "one"}, + {Type: "separator"}, + {Label: "More", Submenu: []systray.TrayMenuItem{{Label: "Two", ActionID: "two"}}}, + })) + + info = svc.GetTrayInfo() + require.NotNil(t, info) + assert.Equal(t, "Updated Tooltip", info["tooltip"]) + assert.Equal(t, "Updated Label", info["label"]) + assert.True(t, info["hasIcon"].(bool)) + items, ok := info["menuItems"].([]systray.TrayMenuItem) + require.True(t, ok) + require.Len(t, items, 3) + }) +} + func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) { c := newTestConclave(t) diff --git a/pkg/display/messages.go b/pkg/display/messages.go index 43d4e3f..632e04a 100644 --- a/pkg/display/messages.go +++ b/pkg/display/messages.go @@ -10,3 +10,8 @@ type ActionIDECommand struct { // EventIDECommand is the WS event type for IDE commands. const EventIDECommand EventType = "ide.command" + +// Theme is the display-level theme summary exposed by the service API. +type Theme struct { + IsDark bool `json:"isDark"` +} diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 418e38c..2df119b 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -8,6 +8,7 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error { if m.tray == nil { return fmt.Errorf("tray not initialised") } + m.menuItems = append([]TrayMenuItem(nil), items...) menu := m.buildMenu(items) m.tray.SetMenu(menu) return nil @@ -77,6 +78,11 @@ func (m *Manager) GetCallback(actionID string) (func(), bool) { // GetInfo returns tray status information. func (m *Manager) GetInfo() map[string]any { return map[string]any{ - "active": m.IsActive(), + "active": m.IsActive(), + "tooltip": m.tooltip, + "label": m.label, + "hasIcon": m.hasIcon, + "hasTemplateIcon": m.hasTemplateIcon, + "menuItems": append([]TrayMenuItem(nil), m.menuItems...), } } diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 05ffcdf..cda24c0 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -13,10 +13,15 @@ var defaultIcon []byte // Manager manages the system tray lifecycle. // State that was previously in package-level vars is now on the Manager. type Manager struct { - platform Platform - tray PlatformTray - callbacks map[string]func() - mu sync.RWMutex + platform Platform + tray PlatformTray + callbacks map[string]func() + tooltip string + label string + hasIcon bool + hasTemplateIcon bool + menuItems []TrayMenuItem + mu sync.RWMutex } // NewManager creates a systray Manager. @@ -36,6 +41,9 @@ func (m *Manager) Setup(tooltip, label string) error { m.tray.SetTemplateIcon(defaultIcon) m.tray.SetTooltip(tooltip) m.tray.SetLabel(label) + m.tooltip = tooltip + m.label = label + m.hasTemplateIcon = true return nil } @@ -45,6 +53,7 @@ func (m *Manager) SetIcon(data []byte) error { return fmt.Errorf("tray not initialised") } m.tray.SetIcon(data) + m.hasIcon = len(data) > 0 return nil } @@ -54,6 +63,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error { return fmt.Errorf("tray not initialised") } m.tray.SetTemplateIcon(data) + m.hasTemplateIcon = len(data) > 0 return nil } @@ -63,6 +73,7 @@ func (m *Manager) SetTooltip(text string) error { return fmt.Errorf("tray not initialised") } m.tray.SetTooltip(text) + m.tooltip = text return nil } @@ -72,6 +83,7 @@ func (m *Manager) SetLabel(text string) error { return fmt.Errorf("tray not initialised") } m.tray.SetLabel(text) + m.label = text return nil }