diff --git a/pkg/display/api.go b/pkg/display/api.go new file mode 100644 index 00000000..ea5bcf39 --- /dev/null +++ b/pkg/display/api.go @@ -0,0 +1,586 @@ +package display + +import ( + "context" + + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + "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/notification" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/systray" +) + +// Screen is the public display-screen shape used by the display service API. +type Screen struct { + ID string `json:"id"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + ScaleFactor float64 `json:"scaleFactor"` + IsPrimary bool `json:"isPrimary"` +} + +// WorkArea is a usable screen rectangle. +type WorkArea = screen.Rect + +// FileFilter describes a file type filter for the display service dialogs. +type FileFilter struct { + DisplayName string `json:"displayName"` + Pattern string `json:"pattern"` +} + +// OpenFileOptions configures an open-file dialog. +type OpenFileOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` + DefaultFilename string `json:"defaultFilename,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` +} + +// SaveFileOptions configures a save-file dialog. +type SaveFileOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` + DefaultFilename string `json:"defaultFilename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` +} + +// OpenDirectoryOptions configures a folder picker dialog. +type OpenDirectoryOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` +} + +// TrayMenuItem describes a tray menu entry. +type TrayMenuItem struct { + Label string `json:"label,omitempty"` + ActionID string `json:"actionId,omitempty"` + IsSeparator bool `json:"isSeparator,omitempty"` + Children []TrayMenuItem `json:"children,omitempty"` +} + +// NotificationOptions configures a native notification. +type NotificationOptions struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Subtitle string `json:"subtitle,omitempty"` +} + +// Theme reports whether the active theme is dark. +type Theme struct { + IsDark bool `json:"isDark"` +} + +func (s *Service) GetScreens() []*Screen { + r := s.Core().QUERY(screen.QueryAll{}) + if !r.OK { + return nil + } + screens, ok := r.Value.([]screen.Screen) + if !ok { + return nil + } + result := make([]*Screen, 0, len(screens)) + for i := range screens { + result = append(result, screenToDisplay(&screens[i])) + } + return result +} + +func (s *Service) GetScreen(id string) (*Screen, error) { + r := s.Core().QUERY(screen.QueryByID{ID: id}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, nil + } + scr, _ := r.Value.(*screen.Screen) + return screenToDisplay(scr), nil +} + +func (s *Service) GetPrimaryScreen() (*Screen, error) { + r := s.Core().QUERY(screen.QueryPrimary{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, nil + } + scr, _ := r.Value.(*screen.Screen) + return screenToDisplay(scr), nil +} + +func (s *Service) GetScreenAtPoint(x, y int) (*Screen, error) { + r := s.Core().QUERY(screen.QueryAtPoint{X: x, Y: y}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, nil + } + scr, _ := r.Value.(*screen.Screen) + return screenToDisplay(scr), nil +} + +func (s *Service) GetScreenForWindow(name string) (*Screen, error) { + info, err := s.GetWindowInfo(name) + if err != nil || info == nil { + return nil, err + } + return s.GetScreenAtPoint(info.X+max(info.Width/2, 1), info.Y+max(info.Height/2, 1)) +} + +func (s *Service) GetWorkAreas() []*WorkArea { + r := s.Core().QUERY(screen.QueryWorkAreas{}) + if !r.OK { + return nil + } + areas, ok := r.Value.([]screen.Rect) + if !ok { + return nil + } + result := make([]*WorkArea, 0, len(areas)) + for i := range areas { + area := areas[i] + result = append(result, &area) + } + return result +} + +func (s *Service) OpenSingleFileDialog(opts OpenFileOptions) (string, error) { + paths, err := s.OpenFileDialog(opts) + if err != nil || len(paths) == 0 { + return "", err + } + return paths[0], nil +} + +func (s *Service) OpenFileDialog(opts OpenFileOptions) ([]string, error) { + result := s.Core().Action("dialog.openFile").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenFile{Options: toDialogOpenFileOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, coreerr.E("display.OpenFileDialog", "dialog.openFile action failed", nil) + } + paths, _ := result.Value.([]string) + return paths, nil +} + +func (s *Service) SaveFileDialog(opts SaveFileOptions) (string, error) { + result := s.Core().Action("dialog.saveFile").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskSaveFile{Options: toDialogSaveFileOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", err + } + return "", coreerr.E("display.SaveFileDialog", "dialog.saveFile action failed", nil) + } + path, _ := result.Value.(string) + return path, nil +} + +func (s *Service) OpenDirectoryDialog(opts OpenDirectoryOptions) (string, error) { + result := s.Core().Action("dialog.openDirectory").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenDirectory{Options: toDialogOpenDirectoryOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", err + } + return "", coreerr.E("display.OpenDirectoryDialog", "dialog.openDirectory action failed", nil) + } + path, _ := result.Value.(string) + return path, nil +} + +func (s *Service) ConfirmDialog(title, message string) (bool, error) { + result := s.Core().Action("dialog.question").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskQuestion{ + Title: title, + Message: message, + Buttons: []string{"Yes", "No"}, + }}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return false, err + } + return false, coreerr.E("display.ConfirmDialog", "dialog.question action failed", nil) + } + button, _ := result.Value.(string) + return button == "Yes", nil +} + +func (s *Service) PromptDialog(title, message string) (string, bool, error) { + result := s.Core().Action("dialog.prompt").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskPrompt{Title: title, Message: message}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", false, err + } + return "", false, coreerr.E("display.PromptDialog", "dialog.prompt action failed", nil) + } + prompt, ok := result.Value.(dialog.PromptResult) + if !ok { + return "", false, coreerr.E("display.PromptDialog", "unexpected result type", nil) + } + return prompt.Value, prompt.Confirmed, nil +} + +func (s *Service) SetTrayIcon(icon []byte) error { + result := s.Core().Action("systray.setIcon").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayIcon{Data: icon}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.SetTrayIcon", "systray.setIcon action failed", nil) + } + return nil +} + +func (s *Service) SetTrayTooltip(tooltip string) error { + result := s.Core().Action("systray.setTooltip").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayTooltip{Tooltip: tooltip}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.SetTrayTooltip", "systray.setTooltip action failed", nil) + } + return nil +} + +func (s *Service) SetTrayLabel(label string) error { + result := s.Core().Action("systray.setLabel").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayLabel{Label: label}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.SetTrayLabel", "systray.setLabel action failed", nil) + } + return nil +} + +func (s *Service) SetTrayMenu(items []TrayMenuItem) error { + result := s.Core().Action("systray.setMenu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayMenu{Items: trayMenuItemsToSystray(items)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.SetTrayMenu", "systray.setMenu action failed", nil) + } + return nil +} + +func (s *Service) GetTrayInfo() map[string]any { + r := s.Core().QUERY(systray.QueryInfo{}) + if !r.OK { + return nil + } + info, _ := r.Value.(map[string]any) + return info +} + +func (s *Service) ShowTrayMessage(title, message string) error { + result := s.Core().Action("systray.showMessage").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskShowMessage{Title: title, Message: message}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.ShowTrayMessage", "systray.showMessage action failed", nil) + } + return nil +} + +func (s *Service) ReadClipboard() (string, error) { + r := s.Core().QUERY(clipboard.QueryText{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return "", err + } + return "", nil + } + content, ok := r.Value.(clipboard.ClipboardContent) + if !ok { + return "", coreerr.E("display.ReadClipboard", "unexpected result type", nil) + } + return content.Text, nil +} + +func (s *Service) WriteClipboard(text string) error { + result := s.Core().Action("clipboard.setText").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: clipboard.TaskSetText{Text: text}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.WriteClipboard", "clipboard.setText action failed", nil) + } + return nil +} + +func (s *Service) HasClipboard() bool { + text, err := s.ReadClipboard() + return err == nil && text != "" +} + +func (s *Service) ClearClipboard() error { + result := s.Core().Action("clipboard.clear").Run(context.Background(), core.NewOptions()) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.ClearClipboard", "clipboard.clear action failed", nil) + } + return nil +} + +func (s *Service) ReadClipboardImage() ([]byte, error) { + r := s.Core().QUERY(clipboard.QueryImage{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, nil + } + content, ok := r.Value.(clipboard.ImageContent) + if !ok { + return nil, coreerr.E("display.ReadClipboardImage", "unexpected result type", nil) + } + if !content.HasImage { + return nil, nil + } + return append([]byte(nil), content.Data...), nil +} + +func (s *Service) WriteClipboardImage(data []byte) error { + if len(data) == 0 { + return coreerr.E("display.WriteClipboardImage", "clipboard image data is required", nil) + } + if len(data) > clipboard.MaxImageBytes { + return coreerr.E("display.WriteClipboardImage", "clipboard image exceeds maximum size", nil) + } + result := s.Core().Action("clipboard.setImage").Run(context.Background(), core.NewOptions( + core.Option{Key: "data", Value: append([]byte(nil), data...)}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.WriteClipboardImage", "clipboard.setImage action failed", nil) + } + return nil +} + +func (s *Service) ShowNotification(opts NotificationOptions) error { + return s.sendNotification(notification.NotificationOptions{ + ID: opts.ID, + Title: opts.Title, + Message: opts.Message, + Subtitle: opts.Subtitle, + }) +} + +func (s *Service) ShowInfoNotification(title, message string) error { + return s.sendNotification(notification.NotificationOptions{Title: title, Message: message}) +} + +func (s *Service) ShowWarningNotification(title, message string) error { + return s.sendNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityWarning, + }) +} + +func (s *Service) ShowErrorNotification(title, message string) error { + return s.sendNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityError, + }) +} + +func (s *Service) RequestNotificationPermission() (bool, error) { + r := s.Core().Action("notification.requestPermission").Run(context.Background(), core.NewOptions()) + if !r.OK { + if err, ok := r.Value.(error); ok { + return false, err + } + return false, coreerr.E("display.RequestNotificationPermission", "notification.requestPermission action failed", nil) + } + granted, _ := r.Value.(bool) + return granted, nil +} + +func (s *Service) CheckNotificationPermission() (bool, error) { + r := s.Core().QUERY(notification.QueryPermission{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return false, err + } + return false, coreerr.E("display.CheckNotificationPermission", "notification query failed", nil) + } + status, ok := r.Value.(notification.PermissionStatus) + if !ok { + return false, coreerr.E("display.CheckNotificationPermission", "unexpected result type", nil) + } + return status.Granted, nil +} + +func (s *Service) ClearNotifications() error { + result := s.Core().Action("notification.clear").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskClear{}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.ClearNotifications", "notification.clear action failed", nil) + } + return nil +} + +func (s *Service) SetTheme(theme string) error { + result := s.Core().Action("environment.setTheme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: environment.TaskSetTheme{Theme: theme}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.SetTheme", "environment.setTheme action failed", nil) + } + return nil +} + +func (s *Service) GetTheme() *Theme { + r := s.Core().QUERY(environment.QueryTheme{}) + if !r.OK { + return nil + } + info, _ := r.Value.(environment.ThemeInfo) + return &Theme{IsDark: info.IsDark} +} + +func (s *Service) GetSystemTheme() string { + r := s.Core().QUERY(environment.QueryTheme{}) + if !r.OK { + return "" + } + info, _ := r.Value.(environment.ThemeInfo) + return info.Theme +} + +func (s *Service) sendNotification(opts notification.NotificationOptions) error { + result := s.Core().Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: opts}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return coreerr.E("display.sendNotification", "notification.send action failed", nil) + } + return nil +} + +func screenToDisplay(scr *screen.Screen) *Screen { + if scr == nil { + return nil + } + return &Screen{ + ID: scr.ID, + Name: scr.Name, + X: scr.Bounds.X, + Y: scr.Bounds.Y, + Width: scr.Bounds.Width, + Height: scr.Bounds.Height, + ScaleFactor: scr.ScaleFactor, + IsPrimary: scr.IsPrimary, + } +} + +func toDialogOpenFileOptions(opts OpenFileOptions) dialog.OpenFileOptions { + return dialog.OpenFileOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + Filename: opts.DefaultFilename, + AllowMultiple: opts.AllowMultiple, + Filters: toDialogFileFilters(opts.Filters), + } +} + +func toDialogSaveFileOptions(opts SaveFileOptions) dialog.SaveFileOptions { + return dialog.SaveFileOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + Filename: opts.DefaultFilename, + Filters: toDialogFileFilters(opts.Filters), + } +} + +func toDialogOpenDirectoryOptions(opts OpenDirectoryOptions) dialog.OpenDirectoryOptions { + return dialog.OpenDirectoryOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + } +} + +func toDialogFileFilters(filters []FileFilter) []dialog.FileFilter { + if len(filters) == 0 { + return nil + } + result := make([]dialog.FileFilter, 0, len(filters)) + for _, filter := range filters { + result = append(result, dialog.FileFilter{ + DisplayName: filter.DisplayName, + Pattern: filter.Pattern, + }) + } + return result +} + +func trayMenuItemsToSystray(items []TrayMenuItem) []systray.TrayMenuItem { + if len(items) == 0 { + return nil + } + result := make([]systray.TrayMenuItem, 0, len(items)) + for _, item := range items { + converted := systray.TrayMenuItem{ + Label: item.Label, + ActionID: item.ActionID, + } + if item.IsSeparator { + converted.Type = "separator" + } + if len(item.Children) > 0 { + converted.Submenu = trayMenuItemsToSystray(item.Children) + } + result = append(result, converted) + } + return result +}