gui/pkg/display/display.go

1293 lines
32 KiB
Go

package display
import (
"context"
"fmt"
"forge.lthn.ai/core/gui/pkg/core"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// Options holds configuration for the display service.
// This struct is used to configure the display service at startup.
type Options struct{}
// Service manages windowing, dialogs, and other visual elements.
// It is the primary interface for interacting with the UI.
type Service struct {
*core.ServiceRuntime[Options]
app App
config Options
windowStates *WindowStateManager
layouts *LayoutManager
notifier *notifications.NotificationService
events *WSEventManager
}
// newDisplayService contains the common logic for initializing a Service struct.
// It is called by the New function.
func newDisplayService() (*Service, error) {
return &Service{}, nil
}
// New is the constructor for the display service.
// It creates a new Service and returns it.
//
// example:
//
// displayService, err := display.New()
// if err != nil {
// log.Fatal(err)
// }
func New() (*Service, error) {
s, err := newDisplayService()
if err != nil {
return nil, err
}
return s, nil
}
// Register creates and registers a new display service with the given Core instance.
// This wires up the ServiceRuntime so the service can access other services.
func Register(c *core.Core) (any, error) {
s, err := New()
if err != nil {
return nil, err
}
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
return s, nil
}
// ServiceName returns the canonical name for this service.
func (s *Service) ServiceName() string {
return "github.com/host-uk/core/display"
}
// ServiceStartup is called by Wails when the app starts. It initializes the display service
// and sets up the main application window and system tray.
func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
return s.Startup(ctx)
}
// Startup is called when the app starts. It initializes the display service
// and sets up the main application window and system tray.
//
// err := displayService.Startup(ctx)
// if err != nil {
// log.Fatal(err)
// }
func (s *Service) Startup(ctx context.Context) error {
s.app = newWailsApp(application.Get())
s.windowStates = NewWindowStateManager()
s.layouts = NewLayoutManager()
s.events = NewWSEventManager(s)
s.events.SetupWindowEventListeners()
s.app.Logger().Info("Display service started")
s.buildMenu()
s.systemTray()
return s.OpenWindow()
}
// handleOpenWindowAction processes a message to configure and create a new window
// using the specified name and options.
func (s *Service) handleOpenWindowAction(msg map[string]any) error {
opts := parseWindowOptions(msg)
s.app.Window().NewWithOptions(opts)
return nil
}
// parseWindowOptions extracts window configuration from a map and returns it
// as a `application.WebviewWindowOptions` struct. This function is used by
// `handleOpenWindowAction` to parse the incoming message.
func parseWindowOptions(msg map[string]any) application.WebviewWindowOptions {
opts := application.WebviewWindowOptions{}
if name, ok := msg["name"].(string); ok {
opts.Name = name
}
if optsMap, ok := msg["options"].(map[string]any); ok {
if title, ok := optsMap["Title"].(string); ok {
opts.Title = title
}
if width, ok := optsMap["Width"].(float64); ok {
opts.Width = int(width)
}
if height, ok := optsMap["Height"].(float64); ok {
opts.Height = int(height)
}
}
return opts
}
// ShowEnvironmentDialog displays a dialog containing detailed information about
// the application's runtime environment. This is useful for debugging and
// understanding the context in which the application is running.
//
// example:
//
// displayService.ShowEnvironmentDialog()
func (s *Service) ShowEnvironmentDialog() {
envInfo := s.app.Env().Info()
details := "Environment Information:\n\n"
details += fmt.Sprintf("Operating System: %s\n", envInfo.OS)
details += fmt.Sprintf("Architecture: %s\n", envInfo.Arch)
details += fmt.Sprintf("Debug Mode: %t\n\n", envInfo.Debug)
details += fmt.Sprintf("Dark Mode: %t\n\n", s.app.Env().IsDarkMode())
details += "Platform Information:"
// Add platform-specific details
for key, value := range envInfo.PlatformInfo {
details += fmt.Sprintf("\n%s: %v", key, value)
}
if envInfo.OSInfo != nil {
details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s",
envInfo.OSInfo.Name,
envInfo.OSInfo.Version)
}
dialog := s.app.Dialog().Info()
dialog.SetTitle("Environment Information")
dialog.SetMessage(details)
dialog.Show()
}
// OpenWindow creates a new window with the given options. If no options are
// provided, it will use the default options.
//
// example:
//
// err := displayService.OpenWindow(
// display.WithName("my-window"),
// display.WithTitle("My Window"),
// display.WithWidth(800),
// display.WithHeight(600),
// )
// if err != nil {
// log.Fatal(err)
// }
func (s *Service) OpenWindow(opts ...WindowOption) error {
wailsOpts := buildWailsWindowOptions(opts...)
// Apply saved window state (position, size)
if s.windowStates != nil {
wailsOpts = s.windowStates.ApplyState(wailsOpts)
}
window := s.app.Window().NewWithOptions(wailsOpts)
// Set up state tracking for this window
if s.windowStates != nil && window != nil {
s.trackWindowState(wailsOpts.Name, window)
}
return nil
}
// trackWindowState sets up event listeners to track window position/size changes.
func (s *Service) trackWindowState(name string, window *application.WebviewWindow) {
// Register for window events
window.OnWindowEvent(events.Common.WindowDidMove, func(event *application.WindowEvent) {
s.windowStates.CaptureState(name, window)
})
window.OnWindowEvent(events.Common.WindowDidResize, func(event *application.WindowEvent) {
s.windowStates.CaptureState(name, window)
})
// Attach event manager listeners for WebSocket broadcasts
if s.events != nil {
s.events.AttachWindowListeners(window)
// Emit window create event
s.events.EmitWindowEvent(EventWindowCreate, name, map[string]any{
"name": name,
})
}
// Capture initial state
s.windowStates.CaptureState(name, window)
}
// buildWailsWindowOptions creates Wails window options from the given
// `WindowOption`s. This function is used by `OpenWindow` to construct the
// options for the new window.
func buildWailsWindowOptions(opts ...WindowOption) application.WebviewWindowOptions {
// Default options
winOpts := &Window{
Name: "main",
Title: "Core",
Width: 1280,
Height: 800,
URL: "/",
}
// Apply functional options
for _, opt := range opts {
if opt != nil {
_ = opt(winOpts)
}
}
return *winOpts
}
// monitorScreenChanges listens for theme change events and logs when the screen
// configuration changes.
func (s *Service) monitorScreenChanges() {
s.app.Event().OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) {
s.app.Logger().Info("Screen configuration changed")
})
}
// WindowInfo contains information about a window for MCP.
type WindowInfo struct {
Name string `json:"name"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Maximized bool `json:"maximized"`
}
// ScreenInfo contains information about a display screen.
type ScreenInfo 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"`
Primary bool `json:"primary"`
}
// GetWindowInfo returns information about a window by name.
func (s *Service) GetWindowInfo(name string) (*WindowInfo, error) {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
x, y := wv.Position()
width, height := wv.Size()
return &WindowInfo{
Name: name,
X: x,
Y: y,
Width: width,
Height: height,
Maximized: wv.IsMaximised(),
}, nil
}
}
}
return nil, fmt.Errorf("window not found: %s", name)
}
// ListWindowInfos returns information about all windows.
func (s *Service) ListWindowInfos() []WindowInfo {
windows := s.app.Window().GetAll()
result := make([]WindowInfo, 0, len(windows))
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
x, y := wv.Position()
width, height := wv.Size()
result = append(result, WindowInfo{
Name: wv.Name(),
X: x,
Y: y,
Width: width,
Height: height,
Maximized: wv.IsMaximised(),
})
}
}
return result
}
// GetScreens returns information about all available screens.
func (s *Service) GetScreens() []ScreenInfo {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]ScreenInfo, 0, len(screens))
for _, screen := range screens {
result = append(result, ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: screen.Bounds.X,
Y: screen.Bounds.Y,
Width: screen.Bounds.Width,
Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
})
}
return result
}
// SetWindowPosition moves a window to the specified position.
func (s *Service) SetWindowPosition(name string, x, y int) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetPosition(x, y)
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowSize resizes a window.
func (s *Service) SetWindowSize(name string, width, height int) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetSize(width, height)
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowBounds sets both position and size of a window.
func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetPosition(x, y)
wv.SetSize(width, height)
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// MaximizeWindow maximizes a window.
func (s *Service) MaximizeWindow(name string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.Maximise()
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// RestoreWindow restores a maximized/minimized window.
func (s *Service) RestoreWindow(name string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.Restore()
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// MinimizeWindow minimizes a window.
func (s *Service) MinimizeWindow(name string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.Minimise()
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// FocusWindow brings a window to the front.
func (s *Service) FocusWindow(name string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.Focus()
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// ResetWindowState clears saved window positions.
func (s *Service) ResetWindowState() error {
if s.windowStates != nil {
return s.windowStates.Clear()
}
return nil
}
// GetSavedWindowStates returns all saved window states.
func (s *Service) GetSavedWindowStates() map[string]*WindowState {
if s.windowStates == nil {
return nil
}
result := make(map[string]*WindowState)
for _, name := range s.windowStates.ListStates() {
result[name] = s.windowStates.GetState(name)
}
return result
}
// CreateWindowOptions contains options for creating a new window.
type CreateWindowOptions struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// CreateWindow creates a new window with the specified options.
func (s *Service) CreateWindow(opts CreateWindowOptions) (*WindowInfo, error) {
if opts.Name == "" {
return nil, fmt.Errorf("window name is required")
}
// Set defaults
if opts.Width == 0 {
opts.Width = 800
}
if opts.Height == 0 {
opts.Height = 600
}
if opts.URL == "" {
opts.URL = "/"
}
if opts.Title == "" {
opts.Title = opts.Name
}
wailsOpts := application.WebviewWindowOptions{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
}
window := s.app.Window().NewWithOptions(wailsOpts)
if window == nil {
return nil, fmt.Errorf("failed to create window")
}
// Track window state
if s.windowStates != nil {
s.trackWindowState(opts.Name, window)
}
return &WindowInfo{
Name: opts.Name,
X: opts.X,
Y: opts.Y,
Width: opts.Width,
Height: opts.Height,
}, nil
}
// CloseWindow closes a window by name.
func (s *Service) CloseWindow(name string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.Close()
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowVisibility shows or hides a window.
func (s *Service) SetWindowVisibility(name string, visible bool) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
if visible {
wv.Show()
} else {
wv.Hide()
}
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowAlwaysOnTop sets whether a window stays on top of other windows.
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetAlwaysOnTop(alwaysOnTop)
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowTitle changes a window's title.
func (s *Service) SetWindowTitle(name string, title string) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetTitle(title)
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// SetWindowFullscreen sets a window to fullscreen mode.
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
if fullscreen {
wv.Fullscreen()
} else {
wv.UnFullscreen()
}
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// WorkArea represents usable screen space (excluding dock, menubar, etc).
type WorkArea struct {
ScreenID string `json:"screenId"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// GetWorkAreas returns the usable work area for all screens.
func (s *Service) GetWorkAreas() []WorkArea {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]WorkArea, 0, len(screens))
for _, screen := range screens {
result = append(result, WorkArea{
ScreenID: screen.ID,
X: screen.WorkArea.X,
Y: screen.WorkArea.Y,
Width: screen.WorkArea.Width,
Height: screen.WorkArea.Height,
})
}
return result
}
// GetFocusedWindow returns the name of the currently focused window, or empty if none.
func (s *Service) GetFocusedWindow() string {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.IsFocused() {
return wv.Name()
}
}
}
return ""
}
// SaveLayout saves the current window arrangement as a named layout.
func (s *Service) SaveLayout(name string) error {
if s.layouts == nil {
return fmt.Errorf("layout manager not initialized")
}
// Capture current window states
windowStates := make(map[string]WindowState)
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
x, y := wv.Position()
width, height := wv.Size()
windowStates[wv.Name()] = WindowState{
X: x,
Y: y,
Width: width,
Height: height,
Maximized: wv.IsMaximised(),
}
}
}
return s.layouts.SaveLayout(name, windowStates)
}
// RestoreLayout applies a saved layout, positioning all windows.
func (s *Service) RestoreLayout(name string) error {
if s.layouts == nil {
return fmt.Errorf("layout manager not initialized")
}
layout := s.layouts.GetLayout(name)
if layout == nil {
return fmt.Errorf("layout not found: %s", name)
}
// Apply saved positions to existing windows
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if state, exists := layout.Windows[wv.Name()]; exists {
wv.SetPosition(state.X, state.Y)
wv.SetSize(state.Width, state.Height)
if state.Maximized {
wv.Maximise()
} else {
wv.Restore()
}
}
}
}
return nil
}
// ListLayouts returns all saved layout names with metadata.
func (s *Service) ListLayouts() []LayoutInfo {
if s.layouts == nil {
return nil
}
return s.layouts.ListLayouts()
}
// DeleteLayout removes a saved layout by name.
func (s *Service) DeleteLayout(name string) error {
if s.layouts == nil {
return fmt.Errorf("layout manager not initialized")
}
return s.layouts.DeleteLayout(name)
}
// GetLayout returns a specific layout by name.
func (s *Service) GetLayout(name string) *Layout {
if s.layouts == nil {
return nil
}
return s.layouts.GetLayout(name)
}
// GetEventManager returns the event manager for WebSocket event subscriptions.
func (s *Service) GetEventManager() *WSEventManager {
return s.events
}
// GetWindowTitle returns the title of a window by name.
// Note: Wails v3 doesn't expose a title getter, so we track it ourselves or return the name.
func (s *Service) GetWindowTitle(name string) (string, error) {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
// Window name as fallback since Wails v3 doesn't have a title getter
return name, nil
}
}
}
return "", fmt.Errorf("window not found: %s", name)
}
// GetScreen returns information about a specific screen by ID.
func (s *Service) GetScreen(id string) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.ID == id {
return &ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: screen.Bounds.X,
Y: screen.Bounds.Y,
Width: screen.Bounds.Width,
Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("screen not found: %s", id)
}
// GetPrimaryScreen returns information about the primary screen.
func (s *Service) GetPrimaryScreen() (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.IsPrimary {
return &ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: screen.Bounds.X,
Y: screen.Bounds.Y,
Width: screen.Bounds.Width,
Height: screen.Bounds.Height,
Primary: true,
}, nil
}
}
return nil, fmt.Errorf("no primary screen found")
}
// GetScreenAtPoint returns the screen containing a specific point.
func (s *Service) GetScreenAtPoint(x, y int) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
bounds := screen.Bounds
if x >= bounds.X && x < bounds.X+bounds.Width &&
y >= bounds.Y && y < bounds.Y+bounds.Height {
return &ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: bounds.X,
Y: bounds.Y,
Width: bounds.Width,
Height: bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("no screen found at point (%d, %d)", x, y)
}
// GetScreenForWindow returns the screen containing a specific window.
func (s *Service) GetScreenForWindow(name string) (*ScreenInfo, error) {
// Get window position
info, err := s.GetWindowInfo(name)
if err != nil {
return nil, err
}
// Find screen at window center
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
return s.GetScreenAtPoint(centerX, centerY)
}
// SetWindowBackgroundColour sets the background color of a window with alpha for transparency.
// Note: On Windows, only alpha 0 or 255 are supported. Other values treated as 255.
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
windows := s.app.Window().GetAll()
for _, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if wv.Name() == name {
wv.SetBackgroundColour(application.RGBA{Red: r, Green: g, Blue: b, Alpha: a})
return nil
}
}
}
return fmt.Errorf("window not found: %s", name)
}
// TileMode represents different tiling arrangements.
type TileMode string
const (
TileModeLeft TileMode = "left"
TileModeRight TileMode = "right"
TileModeTop TileMode = "top"
TileModeBottom TileMode = "bottom"
TileModeTopLeft TileMode = "top-left"
TileModeTopRight TileMode = "top-right"
TileModeBottomLeft TileMode = "bottom-left"
TileModeBottomRight TileMode = "bottom-right"
TileModeGrid TileMode = "grid"
)
// TileWindows arranges windows in a tiled layout.
// mode can be: left, right, top, bottom, top-left, top-right, bottom-left, bottom-right, grid
// If windowNames is empty, tiles all windows.
func (s *Service) TileWindows(mode TileMode, windowNames []string) error {
// Get work area for primary screen
workAreas := s.GetWorkAreas()
if len(workAreas) == 0 {
return fmt.Errorf("no work areas available")
}
wa := workAreas[0] // Use primary screen work area
// Get windows to tile
allWindows := s.app.Window().GetAll()
var windowsToTile []*application.WebviewWindow
if len(windowNames) == 0 {
// Tile all windows
for _, w := range allWindows {
if wv, ok := w.(*application.WebviewWindow); ok {
windowsToTile = append(windowsToTile, wv)
}
}
} else {
// Tile specific windows
nameSet := make(map[string]bool)
for _, name := range windowNames {
nameSet[name] = true
}
for _, w := range allWindows {
if wv, ok := w.(*application.WebviewWindow); ok {
if nameSet[wv.Name()] {
windowsToTile = append(windowsToTile, wv)
}
}
}
}
if len(windowsToTile) == 0 {
return fmt.Errorf("no windows to tile")
}
switch mode {
case TileModeLeft:
// All windows on left half
for _, wv := range windowsToTile {
wv.SetPosition(wa.X, wa.Y)
wv.SetSize(wa.Width/2, wa.Height)
}
case TileModeRight:
// All windows on right half
for _, wv := range windowsToTile {
wv.SetPosition(wa.X+wa.Width/2, wa.Y)
wv.SetSize(wa.Width/2, wa.Height)
}
case TileModeTop:
// All windows on top half
for _, wv := range windowsToTile {
wv.SetPosition(wa.X, wa.Y)
wv.SetSize(wa.Width, wa.Height/2)
}
case TileModeBottom:
// All windows on bottom half
for _, wv := range windowsToTile {
wv.SetPosition(wa.X, wa.Y+wa.Height/2)
wv.SetSize(wa.Width, wa.Height/2)
}
case TileModeTopLeft:
for _, wv := range windowsToTile {
wv.SetPosition(wa.X, wa.Y)
wv.SetSize(wa.Width/2, wa.Height/2)
}
case TileModeTopRight:
for _, wv := range windowsToTile {
wv.SetPosition(wa.X+wa.Width/2, wa.Y)
wv.SetSize(wa.Width/2, wa.Height/2)
}
case TileModeBottomLeft:
for _, wv := range windowsToTile {
wv.SetPosition(wa.X, wa.Y+wa.Height/2)
wv.SetSize(wa.Width/2, wa.Height/2)
}
case TileModeBottomRight:
for _, wv := range windowsToTile {
wv.SetPosition(wa.X+wa.Width/2, wa.Y+wa.Height/2)
wv.SetSize(wa.Width/2, wa.Height/2)
}
case TileModeGrid:
// Arrange in a grid
count := len(windowsToTile)
cols := 1
rows := 1
// Calculate optimal grid
for cols*rows < count {
if cols <= rows {
cols++
} else {
rows++
}
}
cellWidth := wa.Width / cols
cellHeight := wa.Height / rows
for i, wv := range windowsToTile {
col := i % cols
row := i / cols
wv.SetPosition(wa.X+col*cellWidth, wa.Y+row*cellHeight)
wv.SetSize(cellWidth, cellHeight)
}
default:
return fmt.Errorf("unknown tile mode: %s", mode)
}
return nil
}
// SnapPosition represents positions for snapping windows.
type SnapPosition string
const (
SnapLeft SnapPosition = "left"
SnapRight SnapPosition = "right"
SnapTop SnapPosition = "top"
SnapBottom SnapPosition = "bottom"
SnapTopLeft SnapPosition = "top-left"
SnapTopRight SnapPosition = "top-right"
SnapBottomLeft SnapPosition = "bottom-left"
SnapBottomRight SnapPosition = "bottom-right"
SnapCenter SnapPosition = "center"
)
// SnapWindow snaps a window to a screen edge or corner.
func (s *Service) SnapWindow(name string, position SnapPosition) error {
// Get window
window, err := s.GetWindowInfo(name)
if err != nil {
return err
}
// Get screen for window
screen, err := s.GetScreenForWindow(name)
if err != nil {
return err
}
// Get work area for this screen
workAreas := s.GetWorkAreas()
var wa *WorkArea
for _, area := range workAreas {
if area.ScreenID == screen.ID {
wa = &area
break
}
}
if wa == nil {
// Fallback to screen bounds
wa = &WorkArea{
ScreenID: screen.ID,
X: screen.X,
Y: screen.Y,
Width: screen.Width,
Height: screen.Height,
}
}
// Calculate position based on snap position
var x, y, width, height int
switch position {
case SnapLeft:
x = wa.X
y = wa.Y
width = wa.Width / 2
height = wa.Height
case SnapRight:
x = wa.X + wa.Width/2
y = wa.Y
width = wa.Width / 2
height = wa.Height
case SnapTop:
x = wa.X
y = wa.Y
width = wa.Width
height = wa.Height / 2
case SnapBottom:
x = wa.X
y = wa.Y + wa.Height/2
width = wa.Width
height = wa.Height / 2
case SnapTopLeft:
x = wa.X
y = wa.Y
width = wa.Width / 2
height = wa.Height / 2
case SnapTopRight:
x = wa.X + wa.Width/2
y = wa.Y
width = wa.Width / 2
height = wa.Height / 2
case SnapBottomLeft:
x = wa.X
y = wa.Y + wa.Height/2
width = wa.Width / 2
height = wa.Height / 2
case SnapBottomRight:
x = wa.X + wa.Width/2
y = wa.Y + wa.Height/2
width = wa.Width / 2
height = wa.Height / 2
case SnapCenter:
// Center the window without resizing
x = wa.X + (wa.Width-window.Width)/2
y = wa.Y + (wa.Height-window.Height)/2
width = window.Width
height = window.Height
default:
return fmt.Errorf("unknown snap position: %s", position)
}
return s.SetWindowBounds(name, x, y, width, height)
}
// StackWindows arranges windows in a cascade (stacked) pattern.
// Each window is offset by the given amount from the previous one.
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
if offsetX == 0 {
offsetX = 30
}
if offsetY == 0 {
offsetY = 30
}
// Get work area for primary screen
workAreas := s.GetWorkAreas()
if len(workAreas) == 0 {
return fmt.Errorf("no work areas available")
}
wa := workAreas[0]
// Get windows to stack
allWindows := s.app.Window().GetAll()
var windowsToStack []*application.WebviewWindow
if len(windowNames) == 0 {
for _, w := range allWindows {
if wv, ok := w.(*application.WebviewWindow); ok {
windowsToStack = append(windowsToStack, wv)
}
}
} else {
nameSet := make(map[string]bool)
for _, name := range windowNames {
nameSet[name] = true
}
for _, w := range allWindows {
if wv, ok := w.(*application.WebviewWindow); ok {
if nameSet[wv.Name()] {
windowsToStack = append(windowsToStack, wv)
}
}
}
}
if len(windowsToStack) == 0 {
return fmt.Errorf("no windows to stack")
}
// Calculate window size (leave room for cascade)
maxOffset := (len(windowsToStack) - 1) * offsetX
windowWidth := wa.Width - maxOffset - 50
maxOffsetY := (len(windowsToStack) - 1) * offsetY
windowHeight := wa.Height - maxOffsetY - 50
// Ensure minimum size
if windowWidth < 400 {
windowWidth = 400
}
if windowHeight < 300 {
windowHeight = 300
}
// Position each window
for i, wv := range windowsToStack {
x := wa.X + (i * offsetX)
y := wa.Y + (i * offsetY)
wv.SetPosition(x, y)
wv.SetSize(windowWidth, windowHeight)
wv.Focus() // Bring to front in order
}
return nil
}
// WorkflowType represents predefined workflow layouts.
type WorkflowType string
const (
WorkflowCoding WorkflowType = "coding"
WorkflowDebugging WorkflowType = "debugging"
WorkflowPresenting WorkflowType = "presenting"
WorkflowSideBySide WorkflowType = "side-by-side"
)
// ApplyWorkflowLayout applies a predefined layout for a specific workflow.
func (s *Service) ApplyWorkflowLayout(workflow WorkflowType) error {
switch workflow {
case WorkflowCoding:
// Main editor takes 70% left, tools on right 30%
return s.applyWorkflowCoding()
case WorkflowDebugging:
// Code on top 60%, debug output on bottom 40%
return s.applyWorkflowDebugging()
case WorkflowPresenting:
// Single window maximized
return s.applyWorkflowPresenting()
case WorkflowSideBySide:
// Two windows side by side 50/50
return s.TileWindows(TileModeGrid, nil)
default:
return fmt.Errorf("unknown workflow: %s", workflow)
}
}
func (s *Service) applyWorkflowCoding() error {
workAreas := s.GetWorkAreas()
if len(workAreas) == 0 {
return fmt.Errorf("no work areas available")
}
wa := workAreas[0]
windows := s.app.Window().GetAll()
if len(windows) == 0 {
return fmt.Errorf("no windows to arrange")
}
// First window gets 70% width on left
if len(windows) >= 1 {
if wv, ok := windows[0].(*application.WebviewWindow); ok {
wv.SetPosition(wa.X, wa.Y)
wv.SetSize(wa.Width*70/100, wa.Height)
}
}
// Remaining windows stack on right 30%
rightX := wa.X + wa.Width*70/100
rightWidth := wa.Width * 30 / 100
remainingHeight := wa.Height / max(1, len(windows)-1)
for i := 1; i < len(windows); i++ {
if wv, ok := windows[i].(*application.WebviewWindow); ok {
wv.SetPosition(rightX, wa.Y+(i-1)*remainingHeight)
wv.SetSize(rightWidth, remainingHeight)
}
}
return nil
}
func (s *Service) applyWorkflowDebugging() error {
workAreas := s.GetWorkAreas()
if len(workAreas) == 0 {
return fmt.Errorf("no work areas available")
}
wa := workAreas[0]
windows := s.app.Window().GetAll()
if len(windows) == 0 {
return fmt.Errorf("no windows to arrange")
}
// First window gets top 60%
if len(windows) >= 1 {
if wv, ok := windows[0].(*application.WebviewWindow); ok {
wv.SetPosition(wa.X, wa.Y)
wv.SetSize(wa.Width, wa.Height*60/100)
}
}
// Remaining windows split bottom 40%
bottomY := wa.Y + wa.Height*60/100
bottomHeight := wa.Height * 40 / 100
remainingWidth := wa.Width / max(1, len(windows)-1)
for i := 1; i < len(windows); i++ {
if wv, ok := windows[i].(*application.WebviewWindow); ok {
wv.SetPosition(wa.X+(i-1)*remainingWidth, bottomY)
wv.SetSize(remainingWidth, bottomHeight)
}
}
return nil
}
func (s *Service) applyWorkflowPresenting() error {
windows := s.app.Window().GetAll()
if len(windows) == 0 {
return fmt.Errorf("no windows to arrange")
}
// Maximize first window, minimize others
for i, w := range windows {
if wv, ok := w.(*application.WebviewWindow); ok {
if i == 0 {
wv.Maximise()
wv.Focus()
} else {
wv.Minimise()
}
}
}
return nil
}