gui/pkg/display/api.go
Snider 4649ee104a
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Return empty screen lists
2026-04-17 18:49:26 +01:00

633 lines
18 KiB
Go

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 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, 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
}