package display import ( "context" "fmt" 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 unexpectedResultType(method string) error { return coreerr.E(method, "unexpected result type", nil) } func failedQuery(method, query string) error { return coreerr.E(method, query+" query failed", nil) } func (s *Service) GetScreens() []*Screen { r := s.Core().QUERY(screen.QueryAll{}) if !r.OK { return []*Screen{} } screens, ok := r.Value.([]screen.Screen) if !ok { return []*Screen{} } 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, failedQuery("display.GetScreen", "screen.queryByID") } scr, ok := r.Value.(*screen.Screen) if !ok { return nil, unexpectedResultType("display.GetScreen") } 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, failedQuery("display.GetPrimaryScreen", "screen.queryPrimary") } scr, ok := r.Value.(*screen.Screen) if !ok { return nil, unexpectedResultType("display.GetPrimaryScreen") } 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, failedQuery("display.GetScreenAtPoint", "screen.queryAtPoint") } scr, ok := r.Value.(*screen.Screen) if !ok { return nil, unexpectedResultType("display.GetScreenAtPoint") } 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 []*WorkArea{} } areas, ok := r.Value.([]screen.Rect) if !ok { return []*WorkArea{} } 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, ok := result.Value.([]string) if !ok { return nil, unexpectedResultType("display.OpenFileDialog") } 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, ok := result.Value.(string) if !ok { return "", unexpectedResultType("display.SaveFileDialog") } 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, ok := result.Value.(string) if !ok { return "", unexpectedResultType("display.OpenDirectoryDialog") } 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, ok := result.Value.(string) if !ok { return false, unexpectedResultType("display.ConfirmDialog") } 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, failedQuery("display.ReadClipboardImage", "clipboard.queryImage") } content, ok := r.Value.(clipboard.ImageContent) if !ok { return nil, unexpectedResultType("display.ReadClipboardImage") } 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, ok := r.Value.(bool) if !ok { return false, unexpectedResultType("display.RequestNotificationPermission") } 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, unexpectedResultType("display.CheckNotificationPermission") } 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{}) info, ok := themeInfoFromQueryResult(s, "display.GetTheme", r) if !ok { return nil } return &Theme{IsDark: info.IsDark} } func (s *Service) GetSystemTheme() string { r := s.Core().QUERY(environment.QueryTheme{}) info, ok := themeInfoFromQueryResult(s, "display.GetSystemTheme", r) if !ok { return "" } return info.Theme } func themeInfoFromQueryResult(s *Service, method string, r core.Result) (environment.ThemeInfo, bool) { if !r.OK { return environment.ThemeInfo{}, false } info, ok := r.Value.(environment.ThemeInfo) if ok { return info, true } if s != nil && s.Core() != nil { s.Core().LogWarn(fmt.Errorf("query=environment.QueryTheme value_type=%T", r.Value), method, "malformed theme query payload") } return environment.ThemeInfo{}, false } 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 }