diff --git a/pkg/dialog/messages.go b/pkg/dialog/messages.go new file mode 100644 index 0000000..131592e --- /dev/null +++ b/pkg/dialog/messages.go @@ -0,0 +1,14 @@ +// pkg/dialog/messages.go +package dialog + +// TaskOpenFile shows an open file dialog. Result: []string (paths) +type TaskOpenFile struct{ Opts OpenFileOptions } + +// TaskSaveFile shows a save file dialog. Result: string (path) +type TaskSaveFile struct{ Opts SaveFileOptions } + +// TaskOpenDirectory shows a directory picker. Result: string (path) +type TaskOpenDirectory struct{ Opts OpenDirectoryOptions } + +// TaskMessageDialog shows a message dialog. Result: string (button clicked) +type TaskMessageDialog struct{ Opts MessageDialogOptions } diff --git a/pkg/dialog/platform.go b/pkg/dialog/platform.go new file mode 100644 index 0000000..80b74d7 --- /dev/null +++ b/pkg/dialog/platform.go @@ -0,0 +1,59 @@ +// pkg/dialog/platform.go +package dialog + +// Platform abstracts the native dialog backend. +type Platform interface { + OpenFile(opts OpenFileOptions) ([]string, error) + SaveFile(opts SaveFileOptions) (string, error) + OpenDirectory(opts OpenDirectoryOptions) (string, error) + MessageDialog(opts MessageDialogOptions) (string, error) +} + +// DialogType represents the type of message dialog. +type DialogType int + +const ( + DialogInfo DialogType = iota + DialogWarning + DialogError + DialogQuestion +) + +// OpenFileOptions contains options for the open file dialog. +type OpenFileOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` +} + +// SaveFileOptions contains options for the save file dialog. +type SaveFileOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` +} + +// OpenDirectoryOptions contains options for the directory picker. +type OpenDirectoryOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` +} + +// MessageDialogOptions contains options for a message dialog. +type MessageDialogOptions struct { + Type DialogType `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} + +// FileFilter represents a file type filter for dialogs. +type FileFilter struct { + DisplayName string `json:"displayName"` + Pattern string `json:"pattern"` + Extensions []string `json:"extensions,omitempty"` +} diff --git a/pkg/dialog/service.go b/pkg/dialog/service.go new file mode 100644 index 0000000..231f3be --- /dev/null +++ b/pkg/dialog/service.go @@ -0,0 +1,57 @@ +// pkg/dialog/service.go +package dialog + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the dialog service. +type Options struct{} + +// Service is a core.Service managing native dialogs via IPC. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// Register creates a factory closure that captures the Platform adapter. +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskOpenFile: + paths, err := s.platform.OpenFile(t.Opts) + return paths, true, err + case TaskSaveFile: + path, err := s.platform.SaveFile(t.Opts) + return path, true, err + case TaskOpenDirectory: + path, err := s.platform.OpenDirectory(t.Opts) + return path, true, err + case TaskMessageDialog: + button, err := s.platform.MessageDialog(t.Opts) + return button, true, err + default: + return nil, false, nil + } +} diff --git a/pkg/dialog/service_test.go b/pkg/dialog/service_test.go new file mode 100644 index 0000000..66fe760 --- /dev/null +++ b/pkg/dialog/service_test.go @@ -0,0 +1,123 @@ +// pkg/dialog/service_test.go +package dialog + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + openFilePaths []string + saveFilePath string + openDirPath string + messageButton string + openFileErr error + saveFileErr error + openDirErr error + messageErr error + lastOpenOpts OpenFileOptions + lastSaveOpts SaveFileOptions + lastDirOpts OpenDirectoryOptions + lastMsgOpts MessageDialogOptions +} + +func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) { + m.lastOpenOpts = opts + return m.openFilePaths, m.openFileErr +} +func (m *mockPlatform) SaveFile(opts SaveFileOptions) (string, error) { + m.lastSaveOpts = opts + return m.saveFilePath, m.saveFileErr +} +func (m *mockPlatform) OpenDirectory(opts OpenDirectoryOptions) (string, error) { + m.lastDirOpts = opts + return m.openDirPath, m.openDirErr +} +func (m *mockPlatform) MessageDialog(opts MessageDialogOptions) (string, error) { + m.lastMsgOpts = opts + return m.messageButton, m.messageErr +} + +func newTestService(t *testing.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + openFilePaths: []string{"/tmp/file.txt"}, + saveFilePath: "/tmp/save.txt", + openDirPath: "/tmp/dir", + messageButton: "OK", + } + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + return mock, c +} + +func TestRegister_Good(t *testing.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "dialog") + assert.NotNil(t, svc) +} + +func TestTaskOpenFile_Good(t *testing.T) { + mock, c := newTestService(t) + mock.openFilePaths = []string{"/a.txt", "/b.txt"} + + result, handled, err := c.PERFORM(TaskOpenFile{ + Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true}, + }) + require.NoError(t, err) + assert.True(t, handled) + paths := result.([]string) + assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths) + assert.Equal(t, "Pick", mock.lastOpenOpts.Title) + assert.True(t, mock.lastOpenOpts.AllowMultiple) +} + +func TestTaskSaveFile_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.PERFORM(TaskSaveFile{ + Opts: SaveFileOptions{Filename: "out.txt"}, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "/tmp/save.txt", result) +} + +func TestTaskOpenDirectory_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.PERFORM(TaskOpenDirectory{ + Opts: OpenDirectoryOptions{Title: "Pick Dir"}, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "/tmp/dir", result) +} + +func TestTaskMessageDialog_Good(t *testing.T) { + mock, c := newTestService(t) + mock.messageButton = "Yes" + + result, handled, err := c.PERFORM(TaskMessageDialog{ + Opts: MessageDialogOptions{ + Type: DialogQuestion, Title: "Confirm", + Message: "Sure?", Buttons: []string{"Yes", "No"}, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "Yes", result) + assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type) +} + +func TestTaskOpenFile_Bad(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskOpenFile{}) + assert.False(t, handled) +}