feat(display): bridge missing GUI features

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 13:17:20 +00:00
parent a1fbcdf6ed
commit 3c5c109c3a
2 changed files with 341 additions and 0 deletions

View file

@ -2,6 +2,7 @@ package display
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
@ -12,6 +13,7 @@ import (
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/browser"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/contextmenu"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/dock"
@ -464,6 +466,120 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
return nil, false, e
}
result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: w})
case "webview:devtools-open":
w, e := wsRequire(msg.Data, "window")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskOpenDevTools{Window: w})
case "webview:devtools-close":
w, e := wsRequire(msg.Data, "window")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskCloseDevTools{Window: w})
case "layout:beside-editor":
editor, _ := msg.Data["editor"].(string)
windowName, _ := msg.Data["window"].(string)
result, handled, err = s.Core().PERFORM(window.TaskBesideEditor{
Editor: editor,
Window: windowName,
})
case "layout:suggest":
windowCount := 0
if count, ok := msg.Data["windowCount"].(float64); ok {
windowCount = int(count)
}
screenWidth := 0
if width, ok := msg.Data["screenWidth"].(float64); ok {
screenWidth = int(width)
}
screenHeight := 0
if height, ok := msg.Data["screenHeight"].(float64); ok {
screenHeight = int(height)
}
if windowCount <= 0 {
windowCount = len(s.ListWindowInfos())
}
if screenWidth <= 0 || screenHeight <= 0 {
screenWidth, screenHeight = s.primaryScreenSize()
}
result, handled, err = s.Core().QUERY(window.QueryLayoutSuggestion{
WindowCount: windowCount,
ScreenWidth: screenWidth,
ScreenHeight: screenHeight,
})
case "clipboard:read-image":
result, handled, err = s.Core().QUERY(clipboard.QueryImage{})
case "clipboard:write-image":
data, ok := msg.Data["data"].(string)
if !ok || data == "" {
return nil, false, fmt.Errorf("ws: missing required field %q", "data")
}
decoded, decodeErr := base64.StdEncoding.DecodeString(data)
if decodeErr != nil {
return nil, false, fmt.Errorf("ws: invalid base64 image data: %w", decodeErr)
}
result, handled, err = s.Core().PERFORM(clipboard.TaskSetImage{Data: decoded})
case "notification:with-actions":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
subtitle, _ := msg.Data["subtitle"].(string)
actions := make([]notification.NotificationAction, 0)
if raw, ok := msg.Data["actions"]; ok {
encoded, _ := json.Marshal(raw)
_ = json.Unmarshal(encoded, &actions)
}
result, handled, err = s.Core().PERFORM(notification.TaskSend{
Opts: notification.NotificationOptions{
Title: title,
Message: message,
Subtitle: subtitle,
Actions: actions,
},
})
case "notification:clear":
result, handled, err = s.Core().PERFORM(notification.TaskClear{})
case "notification:permission-request":
result, handled, err = s.Core().PERFORM(notification.TaskRequestPermission{})
case "notification:permission-check":
result, handled, err = s.Core().QUERY(notification.QueryPermission{})
case "tray:show-message":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(systray.TaskShowMessage{
Title: title,
Message: message,
})
case "dialog:prompt":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: title,
Message: message,
Buttons: []string{"OK", "Cancel"},
},
})
default:
return nil, false, nil
}

View file

@ -2,14 +2,19 @@ package display
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -30,6 +35,95 @@ func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
return nil
}
type mockClipboardPlatform struct {
text string
ok bool
image []byte
imgOk bool
}
func (m *mockClipboardPlatform) Text() (string, bool) { return m.text, m.ok }
func (m *mockClipboardPlatform) SetText(text string) bool {
m.text = text
m.ok = text != ""
return true
}
func (m *mockClipboardPlatform) Image() ([]byte, bool) { return m.image, m.imgOk }
func (m *mockClipboardPlatform) SetImage(data []byte) bool {
m.image = data
m.imgOk = len(data) > 0
return true
}
type mockNotificationPlatform struct {
permGranted bool
sendCalled bool
clearCalled bool
lastOpts notification.NotificationOptions
}
func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return nil
}
func (m *mockNotificationPlatform) SendWithActions(opts notification.NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return nil
}
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return m.permGranted, nil }
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return m.permGranted, nil }
func (m *mockNotificationPlatform) Clear() error {
m.clearCalled = true
return nil
}
type mockDialogPlatform struct {
button string
last dialog.MessageDialogOptions
}
func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { return nil, nil }
func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { return "", nil }
func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) {
return "", nil
}
func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) {
m.last = opts
if m.button != "" {
return m.button, nil
}
return "OK", nil
}
type mockWebviewConnector struct{}
func (m *mockWebviewConnector) Navigate(url string) error { return nil }
func (m *mockWebviewConnector) Click(selector string) error { return nil }
func (m *mockWebviewConnector) Type(selector, text string) error { return nil }
func (m *mockWebviewConnector) Hover(selector string) error { return nil }
func (m *mockWebviewConnector) Select(selector, value string) error { return nil }
func (m *mockWebviewConnector) Check(selector string, checked bool) error { return nil }
func (m *mockWebviewConnector) Evaluate(script string) (any, error) { return nil, nil }
func (m *mockWebviewConnector) Screenshot() ([]byte, error) { return nil, nil }
func (m *mockWebviewConnector) GetURL() (string, error) { return "", nil }
func (m *mockWebviewConnector) GetTitle() (string, error) { return "", nil }
func (m *mockWebviewConnector) GetHTML(selector string) (string, error) { return "", nil }
func (m *mockWebviewConnector) QuerySelector(selector string) (*webview.ElementInfo, error) {
return nil, nil
}
func (m *mockWebviewConnector) QuerySelectorAll(selector string) ([]*webview.ElementInfo, error) {
return nil, nil
}
func (m *mockWebviewConnector) GetConsole() []webview.ConsoleMessage { return nil }
func (m *mockWebviewConnector) ClearConsole() {}
func (m *mockWebviewConnector) SetViewport(width, height int) error { return nil }
func (m *mockWebviewConnector) UploadFile(selector string, paths []string) error {
return nil
}
func (m *mockWebviewConnector) Close() error { return nil }
// --- Test helpers ---
// newTestDisplayService creates a display service registered with Core for IPC testing.
@ -68,6 +162,36 @@ func newTestConclave(t *testing.T) *core.Core {
return c
}
func newExtendedTestConclave(t *testing.T) *core.Core {
t.Helper()
clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true}
notificationPlatform := &mockNotificationPlatform{permGranted: true}
dialogPlatform := &mockDialogPlatform{button: "OK"}
c, err := core.New(
core.WithService(Register(nil)),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithService(screen.Register(&mockScreenPlatform{
screens: []screen.Screen{{
ID: "primary", Name: "Primary", IsPrimary: true,
Size: screen.Size{Width: 2560, Height: 1440},
Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
}},
})),
core.WithService(systray.Register(systray.NewMockPlatform())),
core.WithService(menu.Register(menu.NewMockPlatform())),
core.WithService(clipboard.Register(clipboardPlatform)),
core.WithService(notification.Register(notificationPlatform)),
core.WithService(dialog.Register(dialogPlatform)),
core.WithService(webview.Register()),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return c
}
// --- Tests ---
func TestNew(t *testing.T) {
@ -546,3 +670,104 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, string(data), "default_width")
}
func TestHandleWSMessage_Extended_Good(t *testing.T) {
c := newExtendedTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(
window.WithName("editor"),
window.WithTitle("Editor"),
window.WithSize(1200, 800),
)
_ = svc.OpenWindow(
window.WithName("assistant"),
window.WithTitle("Assistant"),
window.WithSize(900, 800),
)
t.Run("layout suggest", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "layout:suggest"})
require.NoError(t, err)
assert.True(t, handled)
suggestion, ok := result.(window.LayoutSuggestion)
require.True(t, ok)
assert.Equal(t, "side-by-side", suggestion.Mode)
})
t.Run("clipboard image read", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "clipboard:read-image"})
require.NoError(t, err)
assert.True(t, handled)
content, ok := result.(clipboard.ClipboardImageContent)
require.True(t, ok)
assert.True(t, content.HasContent)
assert.NotEmpty(t, content.Base64)
})
t.Run("clipboard image write", func(t *testing.T) {
payload := base64.StdEncoding.EncodeToString([]byte{9, 8, 7})
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "clipboard:write-image",
Data: map[string]any{"data": payload},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("notification actions", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "notification:with-actions",
Data: map[string]any{
"title": "Heads up",
"message": "Choose one",
"actions": []any{
map[string]any{"id": "ok", "label": "OK"},
},
},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("notification clear", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{Action: "notification:clear"})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("webview devtools", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "webview:devtools-open",
Data: map[string]any{"window": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
_, handled, err = svc.handleWSMessage(WSMessage{
Action: "webview:devtools-close",
Data: map[string]any{"window": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("tray message", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:show-message",
Data: map[string]any{"title": "Core", "message": "Ready"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("prompt dialog", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "dialog:prompt",
Data: map[string]any{"title": "Question", "message": "Continue?"},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "OK", result)
})
}