diff --git a/pkg/display/display.go b/pkg/display/display.go index 1158cab..59c9b80 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -51,9 +51,9 @@ type Service struct { events *WSEventManager } -// New is the constructor for the display service. -// Use: svc, err := display.New() -func New() (*Service, error) { +// NewService creates a display service with empty config sections. +// Use: svc, err := display.NewService() +func NewService() (*Service, error) { return &Service{ configData: map[string]map[string]any{ "window": {}, @@ -63,12 +63,17 @@ func New() (*Service, error) { }, nil } +// Deprecated: use NewService. +func New() (*Service, error) { + return NewService() +} + // Register creates a factory closure that captures the Wails app. // Use: core.WithService(display.Register(app)) // Pass nil for testing without a Wails runtime. func Register(wailsApp *application.App) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { - s, err := New() + s, err := NewService() if err != nil { return nil, err } @@ -1028,6 +1033,10 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { case "theme:system": result, handled, err = s.GetSystemTheme(), true, nil case "theme:set": + if theme, ok := msg.Data["theme"].(string); ok && theme != "" { + result, handled, err = nil, true, s.SetThemeMode(theme) + break + } isDark, _ := msg.Data["isDark"].(bool) result, handled, err = nil, true, s.SetTheme(isDark) case "dialog:open-file": @@ -2148,7 +2157,16 @@ func (s *Service) GetSystemTheme() string { // SetTheme overrides the application theme. // Use: _ = svc.SetTheme(true) func (s *Service) SetTheme(isDark bool) error { - _, handled, err := s.Core().PERFORM(environment.TaskSetTheme{IsDark: isDark}) + if isDark { + return s.SetThemeMode("dark") + } + return s.SetThemeMode("light") +} + +// SetThemeMode overrides the application theme using a declarative mode string. +// Use: _ = svc.SetThemeMode("system") +func (s *Service) SetThemeMode(theme string) error { + _, handled, err := s.Core().PERFORM(environment.TaskSetTheme{Theme: theme}) if err != nil { return err } diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index cf9cf95..9a34e97 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -1146,6 +1146,19 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) { assert.True(t, handled) }) + t.Run("theme set", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "theme:set", + Data: map[string]any{"theme": "light"}, + }) + require.NoError(t, err) + assert.True(t, handled) + + theme := svc.GetTheme() + require.NotNil(t, theme) + assert.False(t, theme.IsDark) + }) + t.Run("webview devtools", func(t *testing.T) { _, handled, err := svc.handleWSMessage(WSMessage{ Action: "webview:devtools-open", diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index b524933..b560450 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -17,8 +17,10 @@ type TaskOpenFileManager struct { } // TaskSetTheme applies an application theme override when supported. +// Theme values: "dark", "light", or "system". type TaskSetTheme struct { - IsDark bool `json:"isDark"` + Theme string `json:"theme,omitempty"` + IsDark bool `json:"isDark,omitempty"` } // ActionThemeChanged is broadcast when the system theme changes. diff --git a/pkg/environment/service.go b/pkg/environment/service.go index dfffb63..bfb162b 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -3,6 +3,7 @@ package environment import ( "context" + "fmt" "forge.lthn.ai/core/go/pkg/core" ) @@ -56,10 +57,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { switch q.(type) { case QueryTheme: - isDark := s.platform.IsDarkMode() - if s.overrideDark != nil { - isDark = *s.overrideDark - } + isDark := s.currentTheme() theme := "light" if isDark { theme = "dark" @@ -79,16 +77,51 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskOpenFileManager: return nil, true, s.platform.OpenFileManager(t.Path, t.Select) case TaskSetTheme: - isDark := t.IsDark - s.overrideDark = &isDark - if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok { - if err := setter.SetTheme(isDark); err != nil { - return nil, true, err - } + if err := s.taskSetTheme(t); err != nil { + return nil, true, err } - _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) return nil, true, nil default: return nil, false, nil } } + +func (s *Service) taskSetTheme(task TaskSetTheme) error { + shouldApplyTheme := false + switch task.Theme { + case "dark": + isDark := true + s.overrideDark = &isDark + shouldApplyTheme = true + case "light": + isDark := false + s.overrideDark = &isDark + shouldApplyTheme = true + case "system": + s.overrideDark = nil + case "": + isDark := task.IsDark + s.overrideDark = &isDark + shouldApplyTheme = true + default: + return fmt.Errorf("invalid theme mode: %s", task.Theme) + } + + if shouldApplyTheme { + if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok { + if err := setter.SetTheme(s.currentTheme()); err != nil { + return err + } + } + } + + _ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentTheme()}) + return nil +} + +func (s *Service) currentTheme() bool { + if s.overrideDark != nil { + return *s.overrideDark + } + return s.platform.IsDarkMode() +} diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index d44ee78..b3716e7 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -142,7 +142,7 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) { func TestTaskSetTheme_Good(t *testing.T) { mock, c := newTestService(t) - _, handled, err := c.PERFORM(TaskSetTheme{IsDark: false}) + _, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"}) require.NoError(t, err) assert.True(t, handled) assert.True(t, mock.setThemeSeen) @@ -154,3 +154,18 @@ func TestTaskSetTheme_Good(t *testing.T) { assert.False(t, theme.IsDark) assert.Equal(t, "light", theme.Theme) } + +func TestTaskSetTheme_Compatibility_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSetTheme{IsDark: true}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.setThemeSeen) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.True(t, theme.IsDark) + assert.Equal(t, "dark", theme.Theme) +} diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 9ac26df..939d1ad 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -8,6 +8,7 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/display" + "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/webview" @@ -54,6 +55,26 @@ func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) e func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil } func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil } +type mockEnvironmentPlatform struct { + isDark bool +} + +func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo { + return environment.EnvironmentInfo{} +} +func (m *mockEnvironmentPlatform) AccentColour() string { return "" } +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 mockScreenPlatform struct { screens []screen.Screen } @@ -109,6 +130,23 @@ func TestMCP_Good_DialogMessage(t *testing.T) { assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity) } +func TestMCP_Good_ThemeSetString(t *testing.T) { + mock := &mockEnvironmentPlatform{isDark: true} + c, err := core.New( + core.WithService(environment.Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + sub := New(c) + _, result, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"}) + require.NoError(t, err) + assert.Equal(t, "light", result.Theme.Theme) + assert.False(t, result.Theme.IsDark) + assert.False(t, mock.isDark) +} + func TestMCP_Good_WindowTitleSetAlias(t *testing.T) { c, err := core.New( core.WithService(window.Register(window.NewMockPlatform())), diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index b224296..8853f47 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -50,18 +50,26 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The // --- theme_set --- type ThemeSetInput struct { - IsDark bool `json:"isDark"` + Theme string `json:"theme"` } type ThemeSetOutput struct { - Success bool `json:"success"` + Theme environment.ThemeInfo `json:"theme"` } func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) { - _, _, err := s.core.PERFORM(environment.TaskSetTheme{IsDark: input.IsDark}) + _, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme}) if err != nil { return nil, ThemeSetOutput{}, err } - return nil, ThemeSetOutput{Success: true}, nil + result, _, err := s.core.QUERY(environment.QueryTheme{}) + if err != nil { + return nil, ThemeSetOutput{}, err + } + theme, ok := result.(environment.ThemeInfo) + if !ok { + return nil, ThemeSetOutput{}, fmt.Errorf("unexpected result type from theme query") + } + return nil, ThemeSetOutput{Theme: theme}, nil } // --- Registration --- @@ -69,5 +77,5 @@ func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input Th func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) - mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set or override the application theme"}, s.themeSet) + mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set the application theme override"}, s.themeSet) }