From dc53b04d2aed933a2f0ef897fd42da033fda38af Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 08:30:58 +0000 Subject: [PATCH] refactor(ax): make window bounds and state updates more declarative Co-Authored-By: Virgil --- pkg/display/README.md | 1 + pkg/display/display.go | 186 +++++++++++++---------------------- pkg/display/docs/backend.md | 4 +- pkg/display/docs/overview.md | 1 + pkg/window/messages.go | 12 +++ pkg/window/state.go | 88 ++++++++--------- 6 files changed, 130 insertions(+), 162 deletions(-) diff --git a/pkg/display/README.md b/pkg/display/README.md index 570b2a0..21aec58 100644 --- a/pkg/display/README.md +++ b/pkg/display/README.md @@ -24,5 +24,6 @@ Windows are created from a `window.Window` spec instead of a fluent option chain. Use `OpenWindow(window.Window{})` for the default app window, or `CreateWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})` when you need a named window and the returned `WindowInfo`. +Use `SetWindowBounds("editor", 100, 200, 1280, 720)` when you need to move and resize a window in one step. The same spec shape is used by layout restore, tiling, snapping, and workflow presets. diff --git a/pkg/display/display.go b/pkg/display/display.go index 659ef19..1c26c69 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -95,138 +95,92 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { s.buildMenu() s.setupTray() case window.ActionWindowOpened: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name, - Data: map[string]any{"name": m.Name}}) - } + s.emit(Event{Type: EventWindowCreate, Window: m.Name, + Data: map[string]any{"name": m.Name}}) case window.ActionWindowClosed: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowClose, Window: m.Name, - Data: map[string]any{"name": m.Name}}) - } + s.emit(Event{Type: EventWindowClose, Window: m.Name, + Data: map[string]any{"name": m.Name}}) case window.ActionWindowMoved: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowMove, Window: m.Name, - Data: map[string]any{"x": m.X, "y": m.Y}}) - } + s.emit(Event{Type: EventWindowMove, Window: m.Name, + Data: map[string]any{"x": m.X, "y": m.Y}}) case window.ActionWindowResized: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowResize, Window: m.Name, - Data: map[string]any{"width": m.Width, "height": m.Height}}) - } + s.emit(Event{Type: EventWindowResize, Window: m.Name, + Data: map[string]any{"width": m.Width, "height": m.Height}}) case window.ActionWindowFocused: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name}) - } + s.emit(Event{Type: EventWindowFocus, Window: m.Name}) case window.ActionWindowBlurred: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name}) - } + s.emit(Event{Type: EventWindowBlur, Window: m.Name}) case systray.ActionTrayClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventTrayClick}) - } + s.emit(Event{Type: EventTrayClick}) case systray.ActionTrayMenuItemClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventTrayMenuItemClick, - Data: map[string]any{"actionId": m.ActionID}}) - } + s.emit(Event{Type: EventTrayMenuItemClick, + Data: map[string]any{"actionId": m.ActionID}}) s.handleTrayAction(m.ActionID) case environment.ActionThemeChanged: - if s.events != nil { - theme := "light" - if m.IsDark { - theme = "dark" - } - s.events.Emit(Event{Type: EventThemeChange, - Data: map[string]any{"isDark": m.IsDark, "theme": theme}}) + theme := "light" + if m.IsDark { + theme = "dark" } + s.emit(Event{Type: EventThemeChange, + Data: map[string]any{"isDark": m.IsDark, "theme": theme}}) case notification.ActionNotificationClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventNotificationClick, - Data: map[string]any{"id": m.ID}}) - } + s.emit(Event{Type: EventNotificationClick, + Data: map[string]any{"id": m.ID}}) case screen.ActionScreensChanged: - if s.events != nil { - s.events.Emit(Event{Type: EventScreenChange, - Data: map[string]any{"screens": m.Screens}}) - } + s.emit(Event{Type: EventScreenChange, + Data: map[string]any{"screens": m.Screens}}) case keybinding.ActionTriggered: - if s.events != nil { - s.events.Emit(Event{Type: EventKeybindingTriggered, - Data: map[string]any{"accelerator": m.Accelerator}}) - } + s.emit(Event{Type: EventKeybindingTriggered, + Data: map[string]any{"accelerator": m.Accelerator}}) case window.ActionFilesDropped: - if s.events != nil { - s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name, - Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) - } + s.emit(Event{Type: EventWindowFileDrop, Window: m.Name, + Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) case dock.ActionVisibilityChanged: - if s.events != nil { - s.events.Emit(Event{Type: EventDockVisibility, - Data: map[string]any{"visible": m.Visible}}) - } + s.emit(Event{Type: EventDockVisibility, + Data: map[string]any{"visible": m.Visible}}) case lifecycle.ActionApplicationStarted: - if s.events != nil { - s.events.Emit(Event{Type: EventAppStarted}) - } + s.emit(Event{Type: EventAppStarted}) case lifecycle.ActionOpenedWithFile: - if s.events != nil { - s.events.Emit(Event{Type: EventAppOpenedWithFile, - Data: map[string]any{"path": m.Path}}) - } + s.emit(Event{Type: EventAppOpenedWithFile, + Data: map[string]any{"path": m.Path}}) case lifecycle.ActionWillTerminate: - if s.events != nil { - s.events.Emit(Event{Type: EventAppWillTerminate}) - } + s.emit(Event{Type: EventAppWillTerminate}) case lifecycle.ActionDidBecomeActive: - if s.events != nil { - s.events.Emit(Event{Type: EventAppActive}) - } + s.emit(Event{Type: EventAppActive}) case lifecycle.ActionDidResignActive: - if s.events != nil { - s.events.Emit(Event{Type: EventAppInactive}) - } + s.emit(Event{Type: EventAppInactive}) case lifecycle.ActionPowerStatusChanged: - if s.events != nil { - s.events.Emit(Event{Type: EventSystemPowerChange}) - } + s.emit(Event{Type: EventSystemPowerChange}) case lifecycle.ActionSystemSuspend: - if s.events != nil { - s.events.Emit(Event{Type: EventSystemSuspend}) - } + s.emit(Event{Type: EventSystemSuspend}) case lifecycle.ActionSystemResume: - if s.events != nil { - s.events.Emit(Event{Type: EventSystemResume}) - } + s.emit(Event{Type: EventSystemResume}) case contextmenu.ActionItemClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventContextMenuClick, - Data: map[string]any{ - "menuName": m.MenuName, - "actionId": m.ActionID, - "data": m.Data, - }}) - } + s.emit(Event{Type: EventContextMenuClick, + Data: map[string]any{ + "menuName": m.MenuName, + "actionId": m.ActionID, + "data": m.Data, + }}) case webview.ActionConsoleMessage: - if s.events != nil { - s.events.Emit(Event{Type: EventWebviewConsole, Window: m.Window, - Data: map[string]any{"message": m.Message}}) - } + s.emit(Event{Type: EventWebviewConsole, Window: m.Window, + Data: map[string]any{"message": m.Message}}) case webview.ActionException: - if s.events != nil { - s.events.Emit(Event{Type: EventWebviewException, Window: m.Window, - Data: map[string]any{"exception": m.Exception}}) - } + s.emit(Event{Type: EventWebviewException, Window: m.Window, + Data: map[string]any{"exception": m.Exception}}) case ActionIDECommand: - if s.events != nil { - s.events.Emit(Event{Type: EventIDECommand, - Data: map[string]any{"command": m.Command}}) - } + s.emit(Event{Type: EventIDECommand, + Data: map[string]any{"command": m.Command}}) } return nil } +func (s *Service) emit(event Event) { + if s.events != nil { + s.events.Emit(event) + } +} + // WebSocketMessage represents a command received from a WebSocket client. type WebSocketMessage struct { Action string `json:"action"` @@ -618,19 +572,21 @@ func (s *Service) ListWindowInfos() []window.WindowInfo { return list } -// SetWindowPosition moves a window via IPC. +// Example: s.SetWindowPosition("editor", 100, 200) +// Use SetWindowBounds when you are changing position and size together. func (s *Service) SetWindowPosition(name string, x, y int) error { _, err := s.performWindowTask("display.SetWindowPosition", window.TaskSetPosition{Name: name, X: x, Y: y}) return err } -// SetWindowSize resizes a window via IPC. +// Example: s.SetWindowSize("editor", 1280, 720) +// Use SetWindowBounds when you are changing position and size together. func (s *Service) SetWindowSize(name string, width, height int) error { _, err := s.performWindowTask("display.SetWindowSize", window.TaskSetSize{Name: name, Width: width, Height: height}) return err } -// SetWindowBounds sets both position and size of a window via IPC. +// Example: s.SetWindowBounds("editor", 100, 200, 1280, 720) func (s *Service) SetWindowBounds(name string, x, y, width, height int) error { _, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetBounds{ Name: name, X: x, Y: y, Width: width, Height: height, @@ -700,7 +656,7 @@ func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error return err } -// GetFocusedWindow returns the name of the currently focused window. +// Example: focused := s.GetFocusedWindow() func (s *Service) GetFocusedWindow() string { infos := s.ListWindowInfos() for _, info := range infos { @@ -711,7 +667,7 @@ func (s *Service) GetFocusedWindow() string { return "" } -// GetWindowTitle returns the title of a window by name. +// Example: title, err := s.GetWindowTitle("editor") func (s *Service) GetWindowTitle(name string) (string, error) { info, err := s.GetWindowInfo(name) if err != nil { @@ -723,13 +679,13 @@ func (s *Service) GetWindowTitle(name string) (string, error) { return info.Title, nil } -// ResetWindowState clears saved window positions. +// Example: s.ResetWindowState() func (s *Service) ResetWindowState() error { _, err := s.performWindowTask("display.ResetWindowState", window.TaskResetWindowState{}) return err } -// GetSavedWindowStates returns all saved window states. +// Example: states := s.GetSavedWindowStates() func (s *Service) GetSavedWindowStates() map[string]window.WindowState { result, handled, _ := s.Core().QUERY(window.QuerySavedWindowStates{}) if !handled { @@ -767,13 +723,13 @@ func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) { // --- Layout delegation --- -// SaveLayout saves the current window arrangement as a named layout. +// Example: s.SaveLayout("coding") func (s *Service) SaveLayout(name string) error { _, err := s.performWindowTask("display.SaveLayout", window.TaskSaveLayout{Name: name}) return err } -// RestoreLayout applies a saved layout. +// Example: s.RestoreLayout("coding") func (s *Service) RestoreLayout(name string) error { _, err := s.performWindowTask("display.RestoreLayout", window.TaskRestoreLayout{Name: name}) return err @@ -789,7 +745,7 @@ func (s *Service) ListLayouts() []window.LayoutInfo { return layouts } -// DeleteLayout removes a saved layout by name. +// Example: s.DeleteLayout("coding") func (s *Service) DeleteLayout(name string) error { _, err := s.performWindowTask("display.DeleteLayout", window.TaskDeleteLayout{Name: name}) return err @@ -807,25 +763,25 @@ func (s *Service) GetLayout(name string) *window.Layout { // --- Tiling/snapping delegation --- -// TileWindows arranges windows in a tiled layout. +// Example: s.TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"}) func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { _, err := s.performWindowTask("display.TileWindows", window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}) return err } -// SnapWindow snaps a window to a screen edge or corner. +// Example: s.SnapWindow("editor", window.SnapRight) func (s *Service) SnapWindow(name string, position window.SnapPosition) error { _, err := s.performWindowTask("display.SnapWindow", window.TaskSnapWindow{Name: name, Position: position.String()}) return err } -// StackWindows arranges windows in a cascade pattern. +// Example: s.StackWindows([]string{"editor", "terminal"}, 24, 24) func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { _, err := s.performWindowTask("display.StackWindows", window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}) return err } -// ApplyWorkflowLayout applies a predefined layout for a specific workflow. +// Example: s.ApplyWorkflowLayout(window.WorkflowCoding) func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { _, err := s.performWindowTask("display.ApplyWorkflowLayout", window.TaskApplyWorkflow{ Workflow: workflow.String(), diff --git a/pkg/display/docs/backend.md b/pkg/display/docs/backend.md index 927846c..ccf5e91 100644 --- a/pkg/display/docs/backend.md +++ b/pkg/display/docs/backend.md @@ -17,9 +17,9 @@ The `Service` struct is the main entry point for the display logic. - `CreateWindow(spec window.Window) (*window.WindowInfo, error)`: Opens a named window and returns its info. - `GetWindowInfo(name string) (*window.WindowInfo, error)`: Queries a single window. - `ListWindowInfos() []window.WindowInfo`: Queries all tracked windows. + - `SetWindowBounds(name string, x, y, width, height int) error` - preferred when position and size change together. - `SetWindowPosition(name string, x, y int) error` - `SetWindowSize(name string, width, height int) error` - - `SetWindowBounds(name string, x, y, width, height int) error` - `MaximizeWindow(name string) error` - `MinimizeWindow(name string) error` - `RestoreWindow(name string) error` @@ -43,6 +43,8 @@ svc.CreateWindow(window.Window{ Width: 1200, Height: 800, }) + +svc.SetWindowBounds("editor", 100, 200, 1280, 720) ``` ## Subsystems diff --git a/pkg/display/docs/overview.md b/pkg/display/docs/overview.md index 9d423ad..a5f69ff 100644 --- a/pkg/display/docs/overview.md +++ b/pkg/display/docs/overview.md @@ -15,6 +15,7 @@ The project consists of two main parts: The core service manages the application lifecycle and exposes declarative operations such as: - `OpenWindow(window.Window{})` - `CreateWindow(window.Window{Name: "editor", URL: "/#/editor"})` +- `SetWindowBounds("editor", 100, 200, 1280, 720)` - `SaveLayout("coding")` - `TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})` - `ApplyWorkflowLayout(window.WorkflowCoding)` diff --git a/pkg/window/messages.go b/pkg/window/messages.go index cc3a094..82165d2 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -26,17 +26,20 @@ type TaskOpenWindow struct { type TaskCloseWindow struct{ Name string } +// Example: c.PERFORM(TaskSetPosition{Name: "editor", X: 100, Y: 200}) type TaskSetPosition struct { Name string X, Y int } +// Example: c.PERFORM(TaskSetBounds{Name: "editor", X: 100, Y: 200, Width: 1280, Height: 720}) type TaskSetBounds struct { Name string X, Y int Width, Height int } +// Example: c.PERFORM(TaskSetSize{Name: "editor", Width: 1280, Height: 720}) type TaskSetSize struct { Name string Width, Height int @@ -84,35 +87,44 @@ type QueryLayoutList struct{} type QueryLayoutGet struct{ Name string } +// Example: c.PERFORM(TaskSaveLayout{Name: "coding"}) type TaskSaveLayout struct{ Name string } +// Example: c.PERFORM(TaskRestoreLayout{Name: "coding"}) type TaskRestoreLayout struct{ Name string } +// Example: c.PERFORM(TaskDeleteLayout{Name: "coding"}) type TaskDeleteLayout struct{ Name string } +// Example: c.PERFORM(TaskResetWindowState{}) type TaskResetWindowState struct{} +// Example: c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"editor", "terminal"}}) type TaskTileWindows struct { Mode string // "left-right", "grid", "left-half", "right-half", etc. Windows []string // window names; empty = all } +// Example: c.PERFORM(TaskStackWindows{Windows: []string{"editor", "terminal"}, OffsetX: 24, OffsetY: 24}) type TaskStackWindows struct { Windows []string // window names; empty = all OffsetX int OffsetY int } +// Example: c.PERFORM(TaskSnapWindow{Name: "editor", Position: "right"}) type TaskSnapWindow struct { Name string // window name Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center" } +// Example: c.PERFORM(TaskApplyWorkflow{Workflow: "coding"}) type TaskApplyWorkflow struct { Workflow string Windows []string // window names; empty = all } +// Example: c.PERFORM(TaskSaveConfig{Config: map[string]any{"default_width": 800}}) type TaskSaveConfig struct{ Config map[string]any } type ActionWindowOpened struct{ Name string } diff --git a/pkg/window/state.go b/pkg/window/state.go index ef36cb2..da72f56 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -77,10 +77,7 @@ func (sm *StateManager) SetPath(path string) { return } sm.mu.Lock() - if sm.saveTimer != nil { - sm.saveTimer.Stop() - sm.saveTimer = nil - } + sm.stopSaveTimerLocked() sm.statePath = path sm.states = make(map[string]WindowState) sm.mu.Unlock() @@ -117,10 +114,27 @@ func (sm *StateManager) save() { } func (sm *StateManager) scheduleSave() { + sm.mu.Lock() + sm.stopSaveTimerLocked() + sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save) + sm.mu.Unlock() +} + +func (sm *StateManager) stopSaveTimerLocked() { if sm.saveTimer != nil { sm.saveTimer.Stop() + sm.saveTimer = nil } - sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save) +} + +func (sm *StateManager) updateState(name string, mutate func(*WindowState)) { + sm.mu.Lock() + state := sm.states[name] + mutate(&state) + state.UpdatedAt = time.Now().UnixMilli() + sm.states[name] = state + sm.mu.Unlock() + sm.scheduleSave() } // GetState returns the saved state for a window name. @@ -133,60 +147,42 @@ func (sm *StateManager) GetState(name string) (WindowState, bool) { // SetState saves state for a window name (debounced disk write). func (sm *StateManager) SetState(name string, state WindowState) { - state.UpdatedAt = time.Now().UnixMilli() - sm.mu.Lock() - sm.states[name] = state - sm.mu.Unlock() - sm.scheduleSave() + sm.updateState(name, func(current *WindowState) { + *current = state + }) } // UpdatePosition updates only the position fields. func (sm *StateManager) UpdatePosition(name string, x, y int) { - sm.mu.Lock() - s := sm.states[name] - s.X = x - s.Y = y - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() + sm.updateState(name, func(state *WindowState) { + state.X = x + state.Y = y + }) } // UpdateSize updates only the size fields. func (sm *StateManager) UpdateSize(name string, width, height int) { - sm.mu.Lock() - s := sm.states[name] - s.Width = width - s.Height = height - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() + sm.updateState(name, func(state *WindowState) { + state.Width = width + state.Height = height + }) } // UpdateBounds updates position and size in one state write. func (sm *StateManager) UpdateBounds(name string, x, y, width, height int) { - sm.mu.Lock() - s := sm.states[name] - s.X = x - s.Y = y - s.Width = width - s.Height = height - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() + sm.updateState(name, func(state *WindowState) { + state.X = x + state.Y = y + state.Width = width + state.Height = height + }) } // UpdateMaximized updates the maximized flag. func (sm *StateManager) UpdateMaximized(name string, maximized bool) { - sm.mu.Lock() - s := sm.states[name] - s.Maximized = maximized - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() + sm.updateState(name, func(state *WindowState) { + state.Maximized = maximized + }) } // CaptureState snapshots the current state from a PlatformWindow. @@ -237,8 +233,8 @@ func (sm *StateManager) Clear() { // ForceSync writes state to disk immediately. func (sm *StateManager) ForceSync() { - if sm.saveTimer != nil { - sm.saveTimer.Stop() - } + sm.mu.Lock() + sm.stopSaveTimerLocked() + sm.mu.Unlock() sm.save() }