refactor(gui): make theme override declarative
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-02 20:22:47 +00:00
parent bca53679f1
commit 29dc0d9877
7 changed files with 150 additions and 23 deletions

View file

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

View file

@ -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",

View file

@ -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.

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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())),

View file

@ -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)
}