diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts new file mode 100644 index 0000000..c9d993a --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/index.ts @@ -0,0 +1,4 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export * from "./models.js"; diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts new file mode 100644 index 0000000..857c1be --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/pkg/application/models.ts @@ -0,0 +1,65 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Create as $Create} from "@wailsio/runtime"; + +/** + * ServiceOptions provides optional parameters for calls to [NewService]. + */ +export class ServiceOptions { + /** + * Name can be set to override the name of the service + * for logging and debugging purposes. + * + * If empty, it will default + * either to the value obtained through the [ServiceName] interface, + * or to the type name. + */ + "Name": string; + + /** + * If the service instance implements [http.Handler], + * it will be mounted on the internal asset server + * at the prefix specified by Route. + */ + "Route": string; + + /** + * MarshalError will be called if non-nil + * to marshal to JSON the error values returned by this service's methods. + * + * MarshalError is not allowed to fail, + * but it may return a nil slice to fall back + * to the globally configured error handler. + * + * If the returned slice is not nil, it must contain valid JSON. + */ + "MarshalError": any; + + /** Creates a new ServiceOptions instance. */ + constructor($$source: Partial = {}) { + if (!("Name" in $$source)) { + this["Name"] = ""; + } + if (!("Route" in $$source)) { + this["Route"] = ""; + } + if (!("MarshalError" in $$source)) { + this["MarshalError"] = null; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ServiceOptions instance from a string or object. + */ + static createFrom($$source: any = {}): ServiceOptions { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ServiceOptions($$parsedSource as Partial); + } +} + +export type Window = any; diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts index 63c3e4b..84245b4 100644 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ b/frontend/bindings/voidraft/internal/services/index.ts @@ -19,7 +19,6 @@ import * as ThemeService from "./themeservice.js"; import * as TranslationService from "./translationservice.js"; import * as TrayService from "./trayservice.js"; import * as WindowService from "./windowservice.js"; -import * as WindowSnapService from "./windowsnapservice.js"; export { BackupService, ConfigService, @@ -38,8 +37,7 @@ export { ThemeService, TranslationService, TrayService, - WindowService, - WindowSnapService + WindowService }; export * from "./models.js"; diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts index 391b0a2..45f6358 100644 --- a/frontend/bindings/voidraft/internal/services/models.ts +++ b/frontend/bindings/voidraft/internal/services/models.ts @@ -391,26 +391,6 @@ export class SystemInfo { } } -/** - * WindowSnapService 窗口吸附服务 - */ -export class WindowSnapService { - - /** Creates a new WindowSnapService instance. */ - constructor($$source: Partial = {}) { - - Object.assign(this, $$source); - } - - /** - * Creates a new WindowSnapService instance from a string or object. - */ - static createFrom($$source: any = {}): WindowSnapService { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new WindowSnapService($$parsedSource as Partial); - } -} - // Private type creation functions const $$createType0 = $Create.Map($Create.Any, $Create.Any); var $$createType1 = (function $$initCreateType1(...args): any { diff --git a/frontend/bindings/voidraft/internal/services/windowservice.ts b/frontend/bindings/voidraft/internal/services/windowservice.ts index 478480d..f4d6f55 100644 --- a/frontend/bindings/voidraft/internal/services/windowservice.ts +++ b/frontend/bindings/voidraft/internal/services/windowservice.ts @@ -14,22 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime"; // @ts-ignore: Unused imports import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as $models from "./models.js"; - -/** - * GetOpenDocumentWindows 获取所有文档窗口 - */ -export function GetOpenDocumentWindows(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3057936408) as any; - let $typingPromise = $resultPromise.then(($result: any) => { - return $$createType0($result); - }) as any; - $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); - return $typingPromise; -} - /** * GetOpenWindows 获取所有打开的文档窗口 */ @@ -67,10 +51,10 @@ export function ServiceShutdown(): Promise & { cancel(): void } { } /** - * SetWindowSnapService 设置窗口吸附服务引用 + * ServiceStartup 服务启动时初始化 */ -export function SetWindowSnapService(snapService: $models.WindowSnapService | null): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1105193745, snapService) as any; +export function ServiceStartup(options: application$0.ServiceOptions): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(2432987694, options) as any; return $resultPromise; } diff --git a/frontend/bindings/voidraft/internal/services/windowsnapservice.ts b/frontend/bindings/voidraft/internal/services/windowsnapservice.ts deleted file mode 100644 index b0bd3d0..0000000 --- a/frontend/bindings/voidraft/internal/services/windowsnapservice.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -/** - * WindowSnapService 窗口吸附服务 - * @module - */ - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import {Call as $Call, Create as $Create} from "@wailsio/runtime"; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; - -/** - * Cleanup 清理资源 - */ -export function Cleanup(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2155505498) as any; - return $resultPromise; -} - -/** - * GetCurrentThreshold 获取当前自适应阈值(用于调试或显示) - */ -export function GetCurrentThreshold(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3176419026) as any; - return $resultPromise; -} - -/** - * GetDiagnosticInfo 获取诊断信息(用于调试) - */ -export function GetDiagnosticInfo(): Promise<{ [_: string]: any }> & { cancel(): void } { - let $resultPromise = $Call.ByID(39381769) as any; - let $typingPromise = $resultPromise.then(($result: any) => { - return $$createType0($result); - }) as any; - $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); - return $typingPromise; -} - -/** - * OnWindowSnapConfigChanged 处理窗口吸附配置变更 - */ -export function OnWindowSnapConfigChanged(enabled: boolean): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3794787039, enabled) as any; - return $resultPromise; -} - -/** - * RegisterWindow 注册需要吸附管理的窗口 - */ -export function RegisterWindow(documentID: number, window: application$0.WebviewWindow | null): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1000222723, documentID, window) as any; - return $resultPromise; -} - -/** - * ServiceShutdown 实现服务关闭接口 - */ -export function ServiceShutdown(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1172710495) as any; - return $resultPromise; -} - -/** - * ServiceStartup 服务启动时初始化 - */ -export function ServiceStartup(options: application$0.ServiceOptions): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2456823262, options) as any; - return $resultPromise; -} - -/** - * SetSnapEnabled 设置是否启用窗口吸附 - */ -export function SetSnapEnabled(enabled: boolean): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2280126835, enabled) as any; - return $resultPromise; -} - -/** - * UnregisterWindow 取消注册窗口 - */ -export function UnregisterWindow(documentID: number): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2844230768, documentID) as any; - return $resultPromise; -} - -// Private type creation functions -const $$createType0 = $Create.Map($Create.Any, $Create.Any); diff --git a/internal/services/self_update_service.go b/internal/services/self_update_service.go index 2669342..f0fb67b 100644 --- a/internal/services/self_update_service.go +++ b/internal/services/self_update_service.go @@ -39,11 +39,11 @@ type SelfUpdateService struct { } // NewSelfUpdateService 创建自我更新服务实例 -func NewSelfUpdateService(configService *ConfigService, badgeService *dock.DockService, notificationService *notifications.NotificationService, logger *log.LogService) (*SelfUpdateService, error) { +func NewSelfUpdateService(configService *ConfigService, badgeService *dock.DockService, notificationService *notifications.NotificationService, logger *log.LogService) *SelfUpdateService { // 获取配置 appConfig, err := configService.GetConfig() if err != nil { - return nil, fmt.Errorf("failed to get config: %w", err) + panic(err) } service := &SelfUpdateService{ @@ -55,7 +55,7 @@ func NewSelfUpdateService(configService *ConfigService, badgeService *dock.DockS isUpdating: false, } - return service, nil + return service } // CheckForUpdates 检查更新 diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 299ff49..8ce47aa 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -58,14 +58,11 @@ func NewServiceManager() *ServiceManager { // 初始化文档服务 documentService := NewDocumentService(databaseService, logger) - // 初始化窗口服务 - windowService := NewWindowService(logger, documentService) - // 初始化窗口吸附服务 windowSnapService := NewWindowSnapService(logger, configService) - // 将吸附服务与窗口服务关联 - windowService.SetWindowSnapService(windowSnapService) + // 初始化窗口服务 + windowService := NewWindowService(logger, documentService, windowSnapService) // 初始化系统服务 systemService := NewSystemService(logger) @@ -89,10 +86,7 @@ func NewServiceManager() *ServiceManager { startupService := NewStartupService(configService, logger) // 初始化自我更新服务 - selfUpdateService, err := NewSelfUpdateService(configService, badgeService, notificationService, logger) - if err != nil { - panic(err) - } + selfUpdateService := NewSelfUpdateService(configService, badgeService, notificationService, logger) // 初始化翻译服务 translationService := NewTranslationService(logger) @@ -110,7 +104,7 @@ func NewServiceManager() *ServiceManager { testService := NewTestService(badgeService, notificationService, logger) // 使用新的配置通知系统设置热键配置变更监听 - err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error { + err := configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error { return hotkeyService.UpdateHotkey(enable, hotkey) }) if err != nil { @@ -190,7 +184,6 @@ func (sm *ServiceManager) GetServices() []application.Service { application.NewService(sm.testService), application.NewService(sm.BackupService), application.NewService(sm.httpClientService), - application.NewService(sm.windowSnapService), } return services } diff --git a/internal/services/window_service.go b/internal/services/window_service.go index c3ba1b3..e74b69c 100644 --- a/internal/services/window_service.go +++ b/internal/services/window_service.go @@ -1,6 +1,7 @@ package services import ( + "context" "fmt" "strconv" "voidraft/internal/common/constant" @@ -19,20 +20,22 @@ type WindowService struct { } // NewWindowService 创建新的窗口服务实例 -func NewWindowService(logger *log.LogService, documentService *DocumentService) *WindowService { +func NewWindowService(logger *log.LogService, documentService *DocumentService, windowSnapService *WindowSnapService) *WindowService { if logger == nil { logger = log.New() } return &WindowService{ - logger: logger, - documentService: documentService, + logger: logger, + documentService: documentService, + windowSnapService: windowSnapService, } } -// SetWindowSnapService 设置窗口吸附服务引用 -func (ws *WindowService) SetWindowSnapService(snapService *WindowSnapService) { - ws.windowSnapService = snapService +// ServiceStartup 服务启动时初始化 +func (ws *WindowService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + ws.windowSnapService.UpdateMainWindowCache() + return nil } // OpenDocumentWindow 为指定文档ID打开新窗口 @@ -115,20 +118,6 @@ func (ws *WindowService) GetOpenWindows() []application.Window { return app.Window.GetAll() } -// GetOpenDocumentWindows 获取所有文档窗口 -func (ws *WindowService) GetOpenDocumentWindows() []application.Window { - app := application.Get() - allWindows := app.Window.GetAll() - - var docWindows []application.Window - for _, window := range allWindows { - if window.Name() != constant.VOIDRAFT_MAIN_WINDOW_NAME { - docWindows = append(docWindows, window) - } - } - return docWindows -} - // IsDocumentWindowOpen 检查指定文档的窗口是否已打开 func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool { app := application.Get() @@ -141,7 +130,7 @@ func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool { func (ws *WindowService) ServiceShutdown() error { // 从吸附服务中取消注册所有窗口 if ws.windowSnapService != nil { - windows := ws.GetOpenDocumentWindows() + windows := ws.GetOpenWindows() for _, window := range windows { if documentID, err := strconv.ParseInt(window.Name(), 10, 64); err == nil { ws.windowSnapService.UnregisterWindow(documentID) diff --git a/internal/services/window_snap_service.go b/internal/services/window_snap_service.go index a1f6276..fa2c7cc 100644 --- a/internal/services/window_snap_service.go +++ b/internal/services/window_snap_service.go @@ -1,7 +1,6 @@ package services import ( - "context" "math" "sync" "time" @@ -12,6 +11,16 @@ import ( "github.com/wailsapp/wails/v3/pkg/services/log" ) +// 防抖和检测常量 +const ( + // 移动事件防抖阈值:连续移动事件间隔小于此值时忽略 + debounceThreshold = 30 * time.Millisecond + + // 用户拖拽检测阈值:快速移动被认为是用户主动拖拽 + // 设置为稍大于防抖阈值,确保逻辑一致 + dragDetectionThreshold = 40 * time.Millisecond +) + // WindowSnapService 窗口吸附服务 type WindowSnapService struct { logger *log.LogService @@ -34,6 +43,16 @@ type WindowSnapService struct { // 管理的窗口 managedWindows map[int64]*models.WindowInfo // documentID -> WindowInfo windowRefs map[int64]*application.WebviewWindow // documentID -> Window引用 + + // 窗口尺寸缓存 + windowSizeCache map[int64][2]int // documentID -> [width, height] + + // 事件循环保护 + isUpdatingPosition map[int64]bool // documentID -> 是否正在更新位置 + + // 事件监听器清理函数 + mainMoveUnhook func() // 主窗口移动监听清理函数 + windowMoveUnhooks map[int64]func() // documentID -> 子窗口移动监听清理函数 } // NewWindowSnapService 创建新的窗口吸附服务实例 @@ -60,29 +79,19 @@ func NewWindowSnapService(logger *log.LogService, configService *ConfigService) maxThreshold: 40, // 最大40像素(大屏幕上限) managedWindows: make(map[int64]*models.WindowInfo), windowRefs: make(map[int64]*application.WebviewWindow), + windowSizeCache: make(map[int64][2]int), + isUpdatingPosition: make(map[int64]bool), + windowMoveUnhooks: make(map[int64]func()), } } -// ServiceStartup 服务启动时初始化 -func (wss *WindowSnapService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - // 初始化主窗口位置缓存 - wss.updateMainWindowCache() - - wss.setupMainWindowEvents() - - return nil -} - // RegisterWindow 注册需要吸附管理的窗口 func (wss *WindowSnapService) RegisterWindow(documentID int64, window *application.WebviewWindow) { wss.mu.Lock() defer wss.mu.Unlock() - wss.logger.Info("[WindowSnap] RegisterWindow - DocumentID: %d, SnapEnabled: %v", documentID, wss.snapEnabled) - // 获取初始位置 x, y := window.Position() - wss.logger.Info("[WindowSnap] Initial position - X: %d, Y: %d", x, y) windowInfo := &models.WindowInfo{ DocumentID: documentID, @@ -96,7 +105,13 @@ func (wss *WindowSnapService) RegisterWindow(documentID int64, window *applicati wss.managedWindows[documentID] = windowInfo wss.windowRefs[documentID] = window - wss.logger.Info("[WindowSnap] Managed windows count: %d", len(wss.managedWindows)) + // 初始化窗口尺寸缓存 + wss.updateWindowSizeCacheLocked(documentID, window) + + // 如果这是第一个注册的窗口,启动主窗口事件监听 + if len(wss.managedWindows) == 1 { + wss.setupMainWindowEvents() + } // 为窗口设置移动事件监听 wss.setupWindowEvents(window, windowInfo) @@ -107,8 +122,21 @@ func (wss *WindowSnapService) UnregisterWindow(documentID int64) { wss.mu.Lock() defer wss.mu.Unlock() + // 清理子窗口事件监听 + if unhook, exists := wss.windowMoveUnhooks[documentID]; exists { + unhook() + delete(wss.windowMoveUnhooks, documentID) + } + delete(wss.managedWindows, documentID) delete(wss.windowRefs, documentID) + delete(wss.windowSizeCache, documentID) + delete(wss.isUpdatingPosition, documentID) + + // 如果没有管理的窗口了,取消主窗口事件监听 + if len(wss.managedWindows) == 0 { + wss.cleanupMainWindowEvents() + } } // SetSnapEnabled 设置是否启用窗口吸附 @@ -155,7 +183,7 @@ func (wss *WindowSnapService) calculateAdaptiveThreshold() int { return adaptiveThreshold } -// GetCurrentThreshold 获取当前自适应阈值(用于调试或显示) +// GetCurrentThreshold 获取当前自适应阈值 func (wss *WindowSnapService) GetCurrentThreshold() int { wss.mu.RLock() defer wss.mu.RUnlock() @@ -170,53 +198,125 @@ func (wss *WindowSnapService) OnWindowSnapConfigChanged(enabled bool) error { return nil } -// setupMainWindowEvents 设置主窗口事件监听 +// setupMainWindowEventsLocked 设置主窗口事件监听 func (wss *WindowSnapService) setupMainWindowEvents() { - // 获取主窗口 + // 如果已经设置过,不重复设置 + if wss.mainMoveUnhook != nil { + return + } + + // 在锁外获取主窗口 + wss.mu.Unlock() mainWindow, ok := wss.windowHelper.GetMainWindow() + wss.mu.Lock() + if !ok { return } // 监听主窗口移动事件 - mainWindow.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) { + wss.mainMoveUnhook = mainWindow.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) { wss.onMainWindowMoved() }) + +} + +// cleanupMainWindowEventsLocked 清理主窗口事件监听 +func (wss *WindowSnapService) cleanupMainWindowEvents() { + // 调用清理函数取消监听 + if wss.mainMoveUnhook != nil { + wss.mainMoveUnhook() + wss.mainMoveUnhook = nil + } } // setupWindowEvents 为子窗口设置事件监听 func (wss *WindowSnapService) setupWindowEvents(window *application.WebviewWindow, windowInfo *models.WindowInfo) { - // 监听子窗口移动事件 - window.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) { + // 监听子窗口移动事件,保存清理函数 + unhook := window.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) { wss.onChildWindowMoved(window, windowInfo) }) + + // 保存清理函数以便后续取消监听 + wss.windowMoveUnhooks[windowInfo.DocumentID] = unhook } // updateMainWindowCache 更新主窗口缓存 -func (wss *WindowSnapService) updateMainWindowCache() { +func (wss *WindowSnapService) updateMainWindowCacheLocked() { mainWindow := wss.windowHelper.MustGetMainWindow() if mainWindow == nil { return } + // 在锁外获取窗口信息,避免死锁 + wss.mu.Unlock() x, y := mainWindow.Position() w, h := mainWindow.Size() + wss.mu.Lock() wss.lastMainWindowPos = models.WindowPosition{X: x, Y: y} wss.lastMainWindowSize = [2]int{w, h} } +// UpdateMainWindowCache 更新主窗口缓存 +func (wss *WindowSnapService) UpdateMainWindowCache() { + wss.mu.Lock() + defer wss.mu.Unlock() + wss.updateMainWindowCacheLocked() +} + +// updateWindowSizeCacheLocked 更新窗口尺寸缓存 +func (wss *WindowSnapService) updateWindowSizeCacheLocked(documentID int64, window *application.WebviewWindow) { + // 在锁外获取窗口尺寸,避免死锁 + wss.mu.Unlock() + w, h := window.Size() + wss.mu.Lock() + + wss.windowSizeCache[documentID] = [2]int{w, h} +} + +// getWindowSizeCached 获取缓存的窗口尺寸,如果不存在则实时获取并缓存 +func (wss *WindowSnapService) getWindowSizeCached(documentID int64, window *application.WebviewWindow) (int, int) { + // 先检查缓存 + if size, exists := wss.windowSizeCache[documentID]; exists { + return size[0], size[1] + } + + // 缓存不存在,实时获取并缓存 + wss.updateWindowSizeCacheLocked(documentID, window) + + if size, exists := wss.windowSizeCache[documentID]; exists { + return size[0], size[1] + } + + // 直接返回实时尺寸 + wss.mu.Unlock() + w, h := window.Size() + wss.mu.Lock() + return w, h +} + // onMainWindowMoved 主窗口移动事件处理 func (wss *WindowSnapService) onMainWindowMoved() { if !wss.snapEnabled { return } + // 先在锁外获取主窗口的位置和尺寸 + mainWindow := wss.windowHelper.MustGetMainWindow() + if mainWindow == nil { + return + } + + x, y := mainWindow.Position() + w, h := mainWindow.Size() + wss.mu.Lock() defer wss.mu.Unlock() - // 更新主窗口缓存 - wss.updateMainWindowCache() + // 更新主窗口位置和尺寸缓存 + wss.lastMainWindowPos = models.WindowPosition{X: x, Y: y} + wss.lastMainWindowSize = [2]int{w, h} // 只更新已吸附窗口的位置,无需重新检测所有窗口 for _, windowInfo := range wss.managedWindows { @@ -232,13 +332,20 @@ func (wss *WindowSnapService) onChildWindowMoved(window *application.WebviewWind return } + // 事件循环保护:如果正在更新位置,忽略此次事件 wss.mu.Lock() - defer wss.mu.Unlock() + if wss.isUpdatingPosition[windowInfo.DocumentID] { + wss.mu.Unlock() + return + } + wss.mu.Unlock() - // 获取当前位置 x, y := window.Position() currentPos := models.WindowPosition{X: x, Y: y} + wss.mu.Lock() + defer wss.mu.Unlock() + // 检查是否真的移动了(避免无效触发) if currentPos.X == windowInfo.LastPos.X && currentPos.Y == windowInfo.LastPos.Y { return @@ -270,11 +377,22 @@ func (wss *WindowSnapService) updateSnappedWindowPosition(windowInfo *models.Win expectedX := wss.lastMainWindowPos.X + windowInfo.SnapOffset.X expectedY := wss.lastMainWindowPos.Y + windowInfo.SnapOffset.Y - // 查找对应的window对象并移动 - if window, exists := wss.windowRefs[windowInfo.DocumentID]; exists { - window.SetPosition(expectedX, expectedY) + // 查找对应的window对象 + window, exists := wss.windowRefs[windowInfo.DocumentID] + if !exists { + return } + // 设置更新标志,防止事件循环 + wss.isUpdatingPosition[windowInfo.DocumentID] = true + + wss.mu.Unlock() + window.SetPosition(expectedX, expectedY) + wss.mu.Lock() + + // 清除更新标志 + wss.isUpdatingPosition[windowInfo.DocumentID] = false + windowInfo.LastPos = models.WindowPosition{X: expectedX, Y: expectedY} } @@ -289,9 +407,9 @@ func (wss *WindowSnapService) handleSnappedWindow(window *application.WebviewWin distanceY := math.Abs(float64(currentPos.Y - expectedY)) maxDistance := math.Max(distanceX, distanceY) - // 用户拖拽检测:距离超过阈值且移动很快 + // 用户拖拽检测:距离超过阈值且移动很快(使用统一的拖拽检测阈值) userDragThreshold := float64(wss.calculateAdaptiveThreshold()) - isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < 50*time.Millisecond + isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < dragDetectionThreshold if isUserDrag { // 用户主动拖拽,解除吸附 @@ -310,8 +428,17 @@ func (wss *WindowSnapService) handleUnsnappedWindow(window *application.WebviewW windowInfo.SnapEdge = snapEdge // 执行吸附移动 - targetPos := wss.calculateSnapPosition(snapEdge, currentPos, window) + targetPos := wss.calculateSnapPosition(snapEdge, currentPos, windowInfo.DocumentID, window) + + // 设置更新标志,防止事件循环 + wss.isUpdatingPosition[windowInfo.DocumentID] = true + + wss.mu.Unlock() window.SetPosition(targetPos.X, targetPos.Y) + wss.mu.Lock() + + // 清除更新标志 + wss.isUpdatingPosition[windowInfo.DocumentID] = false // 计算并保存偏移量 windowInfo.SnapOffset.X = targetPos.X - wss.lastMainWindowPos.X @@ -326,32 +453,26 @@ func (wss *WindowSnapService) handleUnsnappedWindow(window *application.WebviewW return false } -// getWindowPosition 获取窗口的位置 -func (wss *WindowSnapService) getWindowPosition(window *application.WebviewWindow) (models.WindowPosition, bool) { - x, y := window.Position() - return models.WindowPosition{X: x, Y: y}, true -} - -// shouldSnapToMainWindow 优化版吸附检测 +// shouldSnapToMainWindow 吸附检测 func (wss *WindowSnapService) shouldSnapToMainWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition, lastMoveTime time.Time) (bool, models.SnapEdge) { - // 防抖:移动太快时不检测, + // 防抖:移动太快时不检测(使用统一的防抖阈值) timeSinceLastMove := time.Since(lastMoveTime) - if timeSinceLastMove < 30*time.Millisecond && timeSinceLastMove > 0 { + if timeSinceLastMove < debounceThreshold { return false, models.SnapEdgeNone } // 使用缓存的主窗口位置和尺寸 if wss.lastMainWindowSize[0] == 0 || wss.lastMainWindowSize[1] == 0 { // 主窗口缓存未初始化,立即更新 - wss.updateMainWindowCache() + wss.updateMainWindowCacheLocked() } mainPos := wss.lastMainWindowPos mainWidth := wss.lastMainWindowSize[0] mainHeight := wss.lastMainWindowSize[1] - // 获取子窗口尺寸 - windowWidth, windowHeight := window.Size() + // 使用缓存的子窗口尺寸,减少系统调用 + windowWidth, windowHeight := wss.getWindowSizeCached(windowInfo.DocumentID, window) // 自适应阈值计算 threshold := float64(wss.calculateAdaptiveThreshold()) @@ -423,14 +544,14 @@ func (wss *WindowSnapService) shouldSnapToMainWindow(window *application.Webview } // calculateSnapPosition 计算吸附目标位置 -func (wss *WindowSnapService) calculateSnapPosition(snapEdge models.SnapEdge, currentPos models.WindowPosition, window *application.WebviewWindow) models.WindowPosition { +func (wss *WindowSnapService) calculateSnapPosition(snapEdge models.SnapEdge, currentPos models.WindowPosition, documentID int64, window *application.WebviewWindow) models.WindowPosition { // 使用缓存的主窗口信息 mainPos := wss.lastMainWindowPos mainWidth := wss.lastMainWindowSize[0] mainHeight := wss.lastMainWindowSize[1] - // 获取子窗口尺寸 - windowWidth, windowHeight := window.Size() + // 使用缓存的子窗口尺寸,减少系统调用 + windowWidth, windowHeight := wss.getWindowSizeCached(documentID, window) switch snapEdge { case models.SnapEdgeRight: @@ -483,9 +604,23 @@ func (wss *WindowSnapService) Cleanup() { wss.mu.Lock() defer wss.mu.Unlock() + // 清理主窗口事件监听 + wss.cleanupMainWindowEvents() + + // 清理所有子窗口事件监听 + for documentID, unhook := range wss.windowMoveUnhooks { + if unhook != nil { + unhook() + } + delete(wss.windowMoveUnhooks, documentID) + } + // 清空管理的窗口 wss.managedWindows = make(map[int64]*models.WindowInfo) wss.windowRefs = make(map[int64]*application.WebviewWindow) + wss.windowSizeCache = make(map[int64][2]int) + wss.isUpdatingPosition = make(map[int64]bool) + wss.windowMoveUnhooks = make(map[int64]func()) } // ServiceShutdown 实现服务关闭接口 diff --git a/internal/services/window_snap_service_test.go b/internal/services/window_snap_service_test.go new file mode 100644 index 0000000..d0da159 --- /dev/null +++ b/internal/services/window_snap_service_test.go @@ -0,0 +1,831 @@ +package services + +import ( + "sync" + "testing" + "time" + "voidraft/internal/models" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/log" +) + +// MockWindow 模拟窗口对象 +type MockWindow struct { + x, y int + w, h int + mu sync.Mutex +} + +func (mw *MockWindow) Position() (int, int) { + mw.mu.Lock() + defer mw.mu.Unlock() + return mw.x, mw.y +} + +func (mw *MockWindow) Size() (int, int) { + mw.mu.Lock() + defer mw.mu.Unlock() + return mw.w, mw.h +} + +func (mw *MockWindow) SetPosition(x, y int) { + mw.mu.Lock() + defer mw.mu.Unlock() + mw.x, mw.y = x, y +} + +// 创建测试用的服务实例 +func createTestService() *WindowSnapService { + logger := log.New() + + service := &WindowSnapService{ + logger: logger, + configService: nil, // 测试中不需要实际的配置服务 + windowHelper: NewWindowHelper(), + snapEnabled: true, + baseThresholdRatio: 0.025, + minThreshold: 8, + maxThreshold: 40, + managedWindows: make(map[int64]*models.WindowInfo), + windowRefs: make(map[int64]*application.WebviewWindow), + windowSizeCache: make(map[int64][2]int), + isUpdatingPosition: make(map[int64]bool), + windowMoveUnhooks: make(map[int64]func()), + lastMainWindowPos: models.WindowPosition{X: 100, Y: 100}, + lastMainWindowSize: [2]int{800, 600}, + } + + return service +} + +// TestDebounceThreshold 测试防抖阈值常量 +func TestDebounceThreshold(t *testing.T) { + if debounceThreshold != 30*time.Millisecond { + t.Errorf("debounceThreshold = %v, want %v", debounceThreshold, 30*time.Millisecond) + } + + if dragDetectionThreshold != 40*time.Millisecond { + t.Errorf("dragDetectionThreshold = %v, want %v", dragDetectionThreshold, 40*time.Millisecond) + } + + // 确保拖拽阈值大于防抖阈值 + if dragDetectionThreshold <= debounceThreshold { + t.Error("dragDetectionThreshold should be greater than debounceThreshold") + } +} + +// TestCalculateAdaptiveThreshold 测试自适应阈值计算 +func TestCalculateAdaptiveThreshold(t *testing.T) { + tests := []struct { + name string + mainWidth int + wantMin int + wantMax int + wantResult int + }{ + { + name: "小窗口", + mainWidth: 400, + wantMin: 8, + wantMax: 40, + wantResult: 10, // 400 * 0.025 = 10 + }, + { + name: "中等窗口", + mainWidth: 1920, + wantMin: 8, + wantMax: 40, + wantResult: 40, // 1920 * 0.025 = 48, 但限制在 40 + }, + { + name: "超小窗口", + mainWidth: 200, + wantMin: 8, + wantMax: 40, + wantResult: 8, // 200 * 0.025 = 5, 但最小是 8 + }, + { + name: "零宽度", + mainWidth: 0, + wantMin: 8, + wantMax: 40, + wantResult: 8, // 应返回最小值 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := createTestService() + service.lastMainWindowSize = [2]int{tt.mainWidth, 600} + + result := service.calculateAdaptiveThreshold() + + if result != tt.wantResult { + t.Errorf("calculateAdaptiveThreshold() = %v, want %v", result, tt.wantResult) + } + + if result < tt.wantMin { + t.Errorf("result %v is less than minimum %v", result, tt.wantMin) + } + + if result > tt.wantMax { + t.Errorf("result %v is greater than maximum %v", result, tt.wantMax) + } + }) + } +} + +// TestWindowSizeCache 测试窗口尺寸缓存 +func TestWindowSizeCache(t *testing.T) { + service := createTestService() + + // 初始化缓存 + service.windowSizeCache[1] = [2]int{640, 480} + + // 测试缓存读取 + service.mu.Lock() + if size, exists := service.windowSizeCache[1]; !exists { + t.Error("Cache should exist for documentID 1") + } else if size[0] != 640 || size[1] != 480 { + t.Errorf("Cache size = [%d, %d], want [640, 480]", size[0], size[1]) + } + service.mu.Unlock() + + // 测试缓存不存在的情况 + service.mu.Lock() + if _, exists := service.windowSizeCache[999]; exists { + t.Error("Cache should not exist for documentID 999") + } + service.mu.Unlock() +} + +// TestSetSnapEnabled 测试启用/禁用吸附 +func TestSetSnapEnabled(t *testing.T) { + service := createTestService() + + // 初始状态 + if !service.snapEnabled { + t.Error("Initial snapEnabled should be true") + } + + // 禁用吸附 + service.SetSnapEnabled(false) + if service.snapEnabled { + t.Error("snapEnabled should be false after SetSnapEnabled(false)") + } + + // 启用吸附 + service.SetSnapEnabled(true) + if !service.snapEnabled { + t.Error("snapEnabled should be true after SetSnapEnabled(true)") + } +} + +// TestUnregisterWindow 测试窗口注销 +func TestUnregisterWindow(t *testing.T) { + service := createTestService() + + // 模拟注册窗口 + documentID := int64(1) + service.managedWindows[documentID] = &models.WindowInfo{DocumentID: documentID} + service.windowSizeCache[documentID] = [2]int{640, 480} + service.isUpdatingPosition[documentID] = false + + // 模拟事件清理函数 + cleanupCalled := false + service.windowMoveUnhooks[documentID] = func() { + cleanupCalled = true + } + + // 验证已注册 + service.mu.RLock() + if _, exists := service.managedWindows[documentID]; !exists { + t.Error("Window should be registered") + } + service.mu.RUnlock() + + // 注销窗口 + service.UnregisterWindow(documentID) + + // 验证已清理 + service.mu.RLock() + if _, exists := service.managedWindows[documentID]; exists { + t.Error("managedWindows should be cleaned up") + } + if _, exists := service.windowSizeCache[documentID]; exists { + t.Error("windowSizeCache should be cleaned up") + } + if _, exists := service.isUpdatingPosition[documentID]; exists { + t.Error("isUpdatingPosition should be cleaned up") + } + if _, exists := service.windowMoveUnhooks[documentID]; exists { + t.Error("windowMoveUnhooks should be cleaned up") + } + service.mu.RUnlock() + + // 验证清理函数被调用 + if !cleanupCalled { + t.Error("Cleanup function should have been called") + } +} + +// TestCleanup 测试资源清理 +func TestCleanup(t *testing.T) { + service := createTestService() + + // 添加一些数据 + service.managedWindows[1] = &models.WindowInfo{DocumentID: 1} + service.managedWindows[2] = &models.WindowInfo{DocumentID: 2} + service.windowSizeCache[1] = [2]int{640, 480} + service.windowSizeCache[2] = [2]int{800, 600} + service.isUpdatingPosition[1] = false + service.isUpdatingPosition[2] = true + + // 添加事件清理函数 + cleanup1Called := false + cleanup2Called := false + service.windowMoveUnhooks[1] = func() { + cleanup1Called = true + } + service.windowMoveUnhooks[2] = func() { + cleanup2Called = true + } + + // 执行清理 + service.Cleanup() + + // 验证所有map都被重置 + service.mu.RLock() + defer service.mu.RUnlock() + + if len(service.managedWindows) != 0 { + t.Errorf("managedWindows length = %d, want 0", len(service.managedWindows)) + } + if len(service.windowSizeCache) != 0 { + t.Errorf("windowSizeCache length = %d, want 0", len(service.windowSizeCache)) + } + if len(service.isUpdatingPosition) != 0 { + t.Errorf("isUpdatingPosition length = %d, want 0", len(service.isUpdatingPosition)) + } + if len(service.windowMoveUnhooks) != 0 { + t.Errorf("windowMoveUnhooks length = %d, want 0", len(service.windowMoveUnhooks)) + } + + // 验证清理函数都被调用 + if !cleanup1Called { + t.Error("Cleanup function for window 1 should have been called") + } + if !cleanup2Called { + t.Error("Cleanup function for window 2 should have been called") + } +} + +// TestEventCleanupOnUnregister 测试事件清理功能 +func TestEventCleanupOnUnregister(t *testing.T) { + service := createTestService() + + // 模拟多个窗口注册 + documentIDs := []int64{1, 2, 3, 4, 5} + cleanupCounters := make(map[int64]int) + + for _, id := range documentIDs { + service.managedWindows[id] = &models.WindowInfo{DocumentID: id} + service.windowSizeCache[id] = [2]int{640, 480} + + // 为每个窗口添加清理函数 + localID := id // 捕获循环变量 + service.windowMoveUnhooks[id] = func() { + cleanupCounters[localID]++ + } + } + + // 验证初始状态 + if len(service.windowMoveUnhooks) != len(documentIDs) { + t.Errorf("Expected %d cleanup hooks, got %d", len(documentIDs), len(service.windowMoveUnhooks)) + } + + // 逐个注销窗口 + for _, id := range documentIDs[:3] { // 注销前3个 + service.UnregisterWindow(id) + } + + // 验证部分清理 + service.mu.RLock() + remainingHooks := len(service.windowMoveUnhooks) + service.mu.RUnlock() + + if remainingHooks != 2 { + t.Errorf("Expected 2 remaining hooks, got %d", remainingHooks) + } + + // 验证清理函数被调用 + for _, id := range documentIDs[:3] { + if cleanupCounters[id] != 1 { + t.Errorf("Cleanup for window %d should have been called once, got %d", id, cleanupCounters[id]) + } + } + + // 验证未注销的窗口清理函数未被调用 + for _, id := range documentIDs[3:] { + if cleanupCounters[id] != 0 { + t.Errorf("Cleanup for window %d should not have been called, got %d", id, cleanupCounters[id]) + } + } + + t.Log("Event cleanup test passed") +} + +// TestEventCleanupNilSafety 测试空清理函数的安全性 +func TestEventCleanupNilSafety(t *testing.T) { + service := createTestService() + + documentID := int64(1) + service.managedWindows[documentID] = &models.WindowInfo{DocumentID: documentID} + // 故意不设置清理函数 + + // 注销窗口不应该panic + defer func() { + if r := recover(); r != nil { + t.Errorf("UnregisterWindow panicked with nil cleanup function: %v", r) + } + }() + + service.UnregisterWindow(documentID) + + t.Log("Nil cleanup function handled safely") +} + +// ========== 并发测试 ========== + +// TestConcurrentRegisterUnregister 测试并发注册和注销窗口 +func TestConcurrentRegisterUnregister(t *testing.T) { + service := createTestService() + const goroutines = 100 + const iterations = 50 + + var wg sync.WaitGroup + wg.Add(goroutines * 2) + + // 并发注册 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + documentID := int64(id*iterations + j) + windowInfo := &models.WindowInfo{ + DocumentID: documentID, + IsSnapped: false, + LastPos: models.WindowPosition{X: 0, Y: 0}, + MoveTime: time.Now(), + } + service.mu.Lock() + service.managedWindows[documentID] = windowInfo + service.windowSizeCache[documentID] = [2]int{640, 480} + service.mu.Unlock() + } + }(i) + } + + // 并发注销 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + time.Sleep(10 * time.Millisecond) // 让注册先执行一点 + for j := 0; j < iterations; j++ { + documentID := int64(id*iterations + j) + service.UnregisterWindow(documentID) + } + }(i) + } + + wg.Wait() + + // 验证数据一致性 + service.mu.RLock() + defer service.mu.RUnlock() + + if len(service.managedWindows) != len(service.windowSizeCache) { + t.Errorf("managedWindows length (%d) != windowSizeCache length (%d)", + len(service.managedWindows), len(service.windowSizeCache)) + } +} + +// TestConcurrentSetSnapEnabled 测试并发启用/禁用吸附 +func TestConcurrentSetSnapEnabled(t *testing.T) { + service := createTestService() + const goroutines = 50 + + var wg sync.WaitGroup + wg.Add(goroutines * 2) + + // 并发启用 + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + service.SetSnapEnabled(true) + } + }() + } + + // 并发禁用 + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + service.SetSnapEnabled(false) + } + }() + } + + wg.Wait() + + // 只要不panic就算成功 + t.Log("Concurrent SetSnapEnabled completed without panic") +} + +// TestConcurrentCacheAccess 测试并发缓存访问 +func TestConcurrentCacheAccess(t *testing.T) { + service := createTestService() + const goroutines = 50 + const operations = 1000 + + // 初始化一些缓存数据 + for i := 0; i < 10; i++ { + service.windowSizeCache[int64(i)] = [2]int{640 + i*10, 480 + i*10} + } + + var wg sync.WaitGroup + wg.Add(goroutines * 3) + + // 并发读取 + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < operations; j++ { + service.mu.RLock() + _ = service.windowSizeCache[int64(j%10)] + service.mu.RUnlock() + } + }() + } + + // 并发写入 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < operations; j++ { + documentID := int64(id*operations + j) + service.mu.Lock() + service.windowSizeCache[documentID] = [2]int{640, 480} + service.mu.Unlock() + } + }(i) + } + + // 并发删除 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < operations; j++ { + documentID := int64(id*operations + j) + service.mu.Lock() + delete(service.windowSizeCache, documentID) + service.mu.Unlock() + } + }(i) + } + + wg.Wait() + + t.Log("Concurrent cache access completed without race conditions") +} + +// TestConcurrentUpdateMainWindowCache 测试并发更新主窗口缓存 +func TestConcurrentUpdateMainWindowCache(t *testing.T) { + service := createTestService() + const goroutines = 50 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < 100; j++ { + service.mu.Lock() + service.lastMainWindowPos = models.WindowPosition{ + X: id*100 + j, + Y: id*100 + j, + } + service.lastMainWindowSize = [2]int{800 + id, 600 + id} + service.mu.Unlock() + } + }(i) + } + + wg.Wait() + + // 验证数据一致性 + service.mu.RLock() + defer service.mu.RUnlock() + + if service.lastMainWindowSize[0] < 800 || service.lastMainWindowSize[0] > 800+goroutines { + t.Errorf("Unexpected main window width: %d", service.lastMainWindowSize[0]) + } +} + +// ========== 性能测试 ========== + +// BenchmarkCalculateAdaptiveThreshold 基准测试:阈值计算 +func BenchmarkCalculateAdaptiveThreshold(b *testing.B) { + service := createTestService() + service.lastMainWindowSize = [2]int{1920, 1080} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = service.calculateAdaptiveThreshold() + } +} + +// BenchmarkWindowSizeCacheHit 基准测试:缓存命中 +func BenchmarkWindowSizeCacheHit(b *testing.B) { + service := createTestService() + documentID := int64(1) + service.windowSizeCache[documentID] = [2]int{640, 480} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + service.mu.RLock() + _, _ = service.windowSizeCache[documentID][0], service.windowSizeCache[documentID][1] + service.mu.RUnlock() + } +} + +// BenchmarkRegisterUnregister 基准测试:注册注销窗口 +func BenchmarkRegisterUnregister(b *testing.B) { + service := createTestService() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + documentID := int64(i) + windowInfo := &models.WindowInfo{ + DocumentID: documentID, + IsSnapped: false, + LastPos: models.WindowPosition{X: 0, Y: 0}, + MoveTime: time.Now(), + } + + service.mu.Lock() + service.managedWindows[documentID] = windowInfo + service.windowSizeCache[documentID] = [2]int{640, 480} + service.mu.Unlock() + + service.UnregisterWindow(documentID) + } +} + +// BenchmarkConcurrentCacheRead 基准测试:并发缓存读取 +func BenchmarkConcurrentCacheRead(b *testing.B) { + service := createTestService() + + // 初始化缓存 + for i := 0; i < 100; i++ { + service.windowSizeCache[int64(i)] = [2]int{640, 480} + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + documentID := int64(i % 100) + service.mu.RLock() + _, _ = service.windowSizeCache[documentID][0], service.windowSizeCache[documentID][1] + service.mu.RUnlock() + i++ + } + }) +} + +// BenchmarkSetSnapEnabled 基准测试:启用禁用吸附 +func BenchmarkSetSnapEnabled(b *testing.B) { + service := createTestService() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + service.SetSnapEnabled(i%2 == 0) + } +} + +// ========== 压力测试 ========== + +// TestHighFrequencyUpdates 测试高频率更新 +func TestHighFrequencyUpdates(t *testing.T) { + service := createTestService() + const updates = 10000 + + start := time.Now() + + for i := 0; i < updates; i++ { + service.mu.Lock() + service.lastMainWindowPos = models.WindowPosition{X: i, Y: i} + service.mu.Unlock() + } + + elapsed := time.Since(start) + avgTime := elapsed / updates + + t.Logf("High frequency updates: %d updates in %v", updates, elapsed) + t.Logf("Average time per update: %v", avgTime) + + if avgTime > 100*time.Microsecond { + t.Logf("Warning: Average update time (%v) is higher than expected", avgTime) + } +} + +// TestMemoryUsage 测试内存使用 +func TestMemoryUsage(t *testing.T) { + service := createTestService() + const windows = 1000 + + // 添加大量窗口 + for i := 0; i < windows; i++ { + documentID := int64(i) + service.mu.Lock() + service.managedWindows[documentID] = &models.WindowInfo{ + DocumentID: documentID, + IsSnapped: false, + LastPos: models.WindowPosition{X: i, Y: i}, + MoveTime: time.Now(), + } + service.windowSizeCache[documentID] = [2]int{640, 480} + service.mu.Unlock() + } + + // 验证数据 + service.mu.RLock() + managedCount := len(service.managedWindows) + cacheCount := len(service.windowSizeCache) + service.mu.RUnlock() + + if managedCount != windows { + t.Errorf("managedWindows count = %d, want %d", managedCount, windows) + } + if cacheCount != windows { + t.Errorf("windowSizeCache count = %d, want %d", cacheCount, windows) + } + + // 清理 + service.Cleanup() + + service.mu.RLock() + afterCleanup := len(service.managedWindows) + len(service.windowSizeCache) + service.mu.RUnlock() + + if afterCleanup != 0 { + t.Errorf("After cleanup, total items = %d, want 0", afterCleanup) + } + + t.Logf("Memory test: Successfully managed %d windows", windows) +} + +// TestWindowResizeCache 测试窗口尺寸变化时缓存更新 +func TestWindowResizeCache(t *testing.T) { + service := createTestService() + + // 初始化缓存 + documentID := int64(1) + service.windowSizeCache[documentID] = [2]int{640, 480} + + // 验证初始缓存 + service.mu.RLock() + size := service.windowSizeCache[documentID] + service.mu.RUnlock() + + if size[0] != 640 || size[1] != 480 { + t.Errorf("Initial cache = [%d, %d], want [640, 480]", size[0], size[1]) + } + + // 模拟窗口尺寸变化 + service.mu.Lock() + service.windowSizeCache[documentID] = [2]int{800, 600} + service.mu.Unlock() + + // 验证缓存更新 + service.mu.RLock() + newSize := service.windowSizeCache[documentID] + service.mu.RUnlock() + + if newSize[0] != 800 || newSize[1] != 600 { + t.Errorf("Updated cache = [%d, %d], want [800, 600]", newSize[0], newSize[1]) + } + + t.Log("Window resize cache test passed") +} + +// TestMainWindowResizeEffect 测试主窗口尺寸变化对吸附窗口的影响 +func TestMainWindowResizeEffect(t *testing.T) { + service := createTestService() + + // 设置初始主窗口尺寸 + service.lastMainWindowPos = models.WindowPosition{X: 100, Y: 100} + service.lastMainWindowSize = [2]int{800, 600} + + // 添加一个已吸附的窗口 + documentID := int64(1) + service.managedWindows[documentID] = &models.WindowInfo{ + DocumentID: documentID, + IsSnapped: true, + SnapEdge: models.SnapEdgeRight, + SnapOffset: models.SnapPosition{X: 800, Y: 0}, // 吸附在右侧 + LastPos: models.WindowPosition{X: 900, Y: 100}, + MoveTime: time.Now(), + } + + // 模拟主窗口尺寸变化 + service.mu.Lock() + service.lastMainWindowSize = [2]int{1000, 600} // 宽度从800变为1000 + service.mu.Unlock() + + // 验证吸附偏移量应该基于新的尺寸重新计算 + // 注意:这里只是验证数据结构,实际的位置更新由事件触发 + + service.mu.RLock() + windowInfo := service.managedWindows[documentID] + service.mu.RUnlock() + + if !windowInfo.IsSnapped { + t.Error("Window should still be snapped after main window resize") + } + + if windowInfo.SnapEdge != models.SnapEdgeRight { + t.Errorf("SnapEdge = %v, want %v", windowInfo.SnapEdge, models.SnapEdgeRight) + } + + t.Log("Main window resize effect test passed") +} + +// TestConcurrentResizeAndMove 测试并发的尺寸变化和移动操作 +func TestConcurrentResizeAndMove(t *testing.T) { + service := createTestService() + const goroutines = 20 + const operations = 100 + + // 初始化一些窗口 + for i := 0; i < 10; i++ { + documentID := int64(i) + service.managedWindows[documentID] = &models.WindowInfo{ + DocumentID: documentID, + IsSnapped: false, + LastPos: models.WindowPosition{X: i * 100, Y: i * 100}, + MoveTime: time.Now(), + } + service.windowSizeCache[documentID] = [2]int{640, 480} + } + + var wg sync.WaitGroup + wg.Add(goroutines * 3) + + // 并发更新窗口尺寸缓存 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < operations; j++ { + documentID := int64(id % 10) + service.mu.Lock() + service.windowSizeCache[documentID] = [2]int{640 + j, 480 + j} + service.mu.Unlock() + } + }(i) + } + + // 并发更新主窗口尺寸 + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < operations; j++ { + service.mu.Lock() + service.lastMainWindowSize = [2]int{800 + j, 600 + j} + service.mu.Unlock() + } + }(i) + } + + // 并发读取缓存 + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < operations; j++ { + documentID := int64(j % 10) + service.mu.RLock() + _ = service.windowSizeCache[documentID] + service.mu.RUnlock() + } + }() + } + + wg.Wait() + + t.Log("Concurrent resize and move test completed without race conditions") +}