refactor(ax): make window bounds and state updates more declarative
Some checks failed
Security Scan / security (push) Failing after 30s
Test / test (push) Successful in 1m24s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 08:30:58 +00:00
parent 5ce1d67f70
commit dc53b04d2a
6 changed files with 130 additions and 162 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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