Added window snapping feature

This commit is contained in:
2025-08-24 16:07:48 +08:00
parent 709998ff9c
commit bc01fdf362
17 changed files with 1224 additions and 176 deletions

View File

@@ -68,6 +68,10 @@ type GeneralConfig struct {
EnableSystemTray bool `json:"enableSystemTray"` // 是否启用系统托盘
StartAtLogin bool `json:"startAtLogin"` // 开机启动设置
// 窗口吸附设置
EnableWindowSnap bool `json:"enableWindowSnap"` // 是否启用窗口吸附功能
SnapThreshold int `json:"snapThreshold"` // 吸附距离阈值(像素)
// 全局热键设置
EnableGlobalHotkey bool `json:"enableGlobalHotkey"` // 是否启用全局热键
GlobalHotkey HotkeyCombo `json:"globalHotkey"` // 全局热键组合
@@ -145,6 +149,8 @@ func NewDefaultAppConfig() *AppConfig {
DataPath: dataDir,
EnableSystemTray: true,
StartAtLogin: false,
EnableWindowSnap: true, // 默认启用窗口吸附
SnapThreshold: 15, // 默认15像素的吸附阈值
EnableGlobalHotkey: false,
GlobalHotkey: HotkeyCombo{
Ctrl: false,

41
internal/models/window.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import "time"
// SnapEdge 表示吸附的边缘类型
type SnapEdge int
const (
SnapEdgeNone SnapEdge = iota // 未吸附
SnapEdgeTop // 吸附到上边缘
SnapEdgeRight // 吸附到右边缘
SnapEdgeBottom // 吸附到下边缘
SnapEdgeLeft // 吸附到左边缘
SnapEdgeTopRight // 吸附到右上角
SnapEdgeBottomRight // 吸附到右下角
SnapEdgeBottomLeft // 吸附到左下角
SnapEdgeTopLeft // 吸附到左上角
)
// WindowPosition 窗口位置
type WindowPosition struct {
X int `json:"x"` // X坐标
Y int `json:"y"` // Y坐标
}
// SnapPosition 表示吸附的相对位置
type SnapPosition struct {
X int `json:"x"` // X轴相对偏移
Y int `json:"y"` // Y轴相对偏移
}
// WindowInfo 窗口信息
type WindowInfo struct {
DocumentID int64 `json:"documentID"` // 文档ID
Title string `json:"title"` // 窗口标题
IsSnapped bool `json:"isSnapped"` // 是否处于吸附状态
SnapOffset SnapPosition `json:"snapOffset"` // 与主窗口的相对位置偏移
SnapEdge SnapEdge `json:"snapEdge"` // 吸附的边缘类型
LastPos WindowPosition `json:"lastPos"` // 上一次记录的窗口位置
MoveTime time.Time `json:"moveTime"` // 上次移动时间,用于判断移动速度
}

View File

@@ -24,6 +24,8 @@ const (
ConfigChangeTypeDataPath ConfigChangeType = "datapath"
// ConfigChangeTypeBackup 备份配置变更
ConfigChangeTypeBackup ConfigChangeType = "backup"
// ConfigChangeTypeWindowSnap 窗口吸附配置变更
ConfigChangeTypeWindowSnap ConfigChangeType = "windowsnap"
)
// ConfigChangeCallback 配置变更回调函数类型
@@ -470,6 +472,29 @@ func CreateBackupConfigListener(name string, callback func(config *models.GitBac
}
}
// CreateWindowSnapConfigListener 创建窗口吸附配置监听器
func CreateWindowSnapConfigListener(name string, callback func(enabled bool, threshold int) error) *ConfigListener {
return &ConfigListener{
Name: name,
ChangeType: ConfigChangeTypeWindowSnap,
Callback: func(changeType ConfigChangeType, oldConfig, newConfig *models.AppConfig) error {
if newConfig == nil {
defaultConfig := models.NewDefaultAppConfig()
return callback(defaultConfig.General.EnableWindowSnap, defaultConfig.General.SnapThreshold)
}
return callback(newConfig.General.EnableWindowSnap, newConfig.General.SnapThreshold)
},
DebounceDelay: 200 * time.Millisecond,
GetConfigFunc: func(k *koanf.Koanf) *models.AppConfig {
var config models.AppConfig
if err := k.Unmarshal("", &config); err != nil {
return nil
}
return &config
},
}
}
// ServiceShutdown 关闭服务
func (cns *ConfigNotificationService) ServiceShutdown() error {
cns.Cleanup()

View File

@@ -308,6 +308,16 @@ func (cs *ConfigService) SetBackupConfigChangeCallback(callback func(config *mod
return cs.notificationService.RegisterListener(backupListener)
}
// SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
func (cs *ConfigService) SetWindowSnapConfigChangeCallback(callback func(enabled bool, threshold int) error) error {
cs.mu.Lock()
defer cs.mu.Unlock()
// 创建窗口吸附配置监听器并注册
windowSnapListener := CreateWindowSnapConfigListener("DefaultWindowSnapConfigListener", callback)
return cs.notificationService.RegisterListener(windowSnapListener)
}
// ServiceShutdown 关闭服务
func (cs *ConfigService) ServiceShutdown() error {
cs.stopWatching()

View File

@@ -13,6 +13,7 @@ type ServiceManager struct {
databaseService *DatabaseService
documentService *DocumentService
windowService *WindowService
windowSnapService *WindowSnapService
migrationService *MigrationService
systemService *SystemService
hotkeyService *HotkeyService
@@ -48,6 +49,12 @@ func NewServiceManager() *ServiceManager {
// 初始化窗口服务
windowService := NewWindowService(logger, documentService)
// 初始化窗口吸附服务
windowSnapService := NewWindowSnapService(logger, configService)
// 将吸附服务与窗口服务关联
windowService.SetWindowSnapService(windowSnapService)
// 初始化系统服务
systemService := NewSystemService(logger)
@@ -108,11 +115,20 @@ func NewServiceManager() *ServiceManager {
panic(err)
}
// 设置窗口吸附配置变更回调
err = configService.SetWindowSnapConfigChangeCallback(func(enabled bool, threshold int) error {
return windowSnapService.OnWindowSnapConfigChanged(enabled, threshold)
})
if err != nil {
panic(err)
}
return &ServiceManager{
configService: configService,
databaseService: databaseService,
documentService: documentService,
windowService: windowService,
windowSnapService: windowSnapService,
migrationService: migrationService,
systemService: systemService,
hotkeyService: hotkeyService,
@@ -136,6 +152,7 @@ func (sm *ServiceManager) GetServices() []application.Service {
application.NewService(sm.databaseService),
application.NewService(sm.documentService),
application.NewService(sm.windowService),
application.NewService(sm.windowSnapService),
application.NewService(sm.keyBindingService),
application.NewService(sm.extensionService),
application.NewService(sm.migrationService),
@@ -221,3 +238,8 @@ func (sm *ServiceManager) GetDocumentService() *DocumentService {
func (sm *ServiceManager) GetThemeService() *ThemeService {
return sm.themeService
}
// GetWindowSnapService 获取窗口吸附服务实例
func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService {
return sm.windowSnapService
}

View File

@@ -9,14 +9,14 @@ import (
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// WindowInfo 窗口信息
// WindowInfo 窗口信息(简化版)
type WindowInfo struct {
Window *application.WebviewWindow
DocumentID int64
Title string
}
// WindowService 窗口管理服务
// WindowService 窗口管理服务(专注于窗口生命周期管理)
type WindowService struct {
logger *log.LogService
documentService *DocumentService
@@ -24,6 +24,9 @@ type WindowService struct {
mainWindow *application.WebviewWindow
windows map[int64]*WindowInfo // documentID -> WindowInfo
mu sync.RWMutex
// 吸附服务引用
windowSnapService *WindowSnapService
}
// NewWindowService 创建新的窗口服务实例
@@ -39,10 +42,20 @@ func NewWindowService(logger *log.LogService, documentService *DocumentService)
}
}
// SetWindowSnapService 设置窗口吸附服务引用
func (ws *WindowService) SetWindowSnapService(snapService *WindowSnapService) {
ws.windowSnapService = snapService
}
// SetAppReferences 设置应用和主窗口引用
func (ws *WindowService) SetAppReferences(app *application.App, mainWindow *application.WebviewWindow) {
ws.app = app
ws.mainWindow = mainWindow
// 如果吸附服务已设置,也为它设置引用
if ws.windowSnapService != nil {
ws.windowSnapService.SetAppReferences(app, mainWindow)
}
}
// OpenDocumentWindow 为指定文档ID打开新窗口
@@ -101,9 +114,14 @@ func (ws *WindowService) OpenDocumentWindow(documentID int64) error {
}
ws.windows[documentID] = windowInfo
// 注册窗口关闭事件
// 注册窗口事件
ws.registerWindowEvents(newWindow, documentID)
// 向吸附服务注册新窗口
if ws.windowSnapService != nil {
ws.windowSnapService.RegisterWindow(documentID, newWindow, doc.Title)
}
return nil
}
@@ -119,12 +137,17 @@ func (ws *WindowService) registerWindowEvents(window *application.WebviewWindow,
func (ws *WindowService) onWindowClosing(documentID int64) {
ws.mu.Lock()
defer ws.mu.Unlock()
windowInfo, exists := ws.windows[documentID]
if exists {
windowInfo.Window.Close()
delete(ws.windows, documentID)
}
// 从吸附服务中取消注册
if ws.windowSnapService != nil {
ws.windowSnapService.UnregisterWindow(documentID)
}
}
}
// GetOpenWindows 获取所有打开的窗口信息
@@ -147,3 +170,18 @@ func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool {
_, exists := ws.windows[documentID]
return exists
}
// ServiceShutdown 实现服务关闭接口
func (ws *WindowService) ServiceShutdown() error {
// 关闭所有窗口
ws.mu.Lock()
defer ws.mu.Unlock()
for documentID := range ws.windows {
if ws.windowSnapService != nil {
ws.windowSnapService.UnregisterWindow(documentID)
}
}
return nil
}

View File

@@ -0,0 +1,603 @@
package services
import (
"context"
"math"
"sync"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// WindowSnapService 窗口吸附服务
type WindowSnapService struct {
logger *log.LogService
configService *ConfigService
app *application.App
mainWindow *application.WebviewWindow
mu sync.RWMutex
// 吸附配置
snapThreshold int // 吸附触发的阈值距离(像素)
snapEnabled bool // 是否启用窗口吸附功能
// 定时器控制
snapTicker *time.Ticker
ctx context.Context
cancel context.CancelFunc
// 性能优化相关
lastMainWindowPos models.WindowPosition // 缓存主窗口上次位置
changedWindows map[int64]bool // 记录哪些窗口发生了变化
skipFrames int // 跳帧计数器,用于降低检测频率
// 监听的窗口列表
managedWindows map[int64]*SnapWindowInfo // documentID -> SnapWindowInfo
}
// SnapWindowInfo 吸附窗口信息
type SnapWindowInfo struct {
Window *application.WebviewWindow
DocumentID int64
Title string
IsSnapped bool // 是否处于吸附状态
SnapOffset models.SnapPosition // 与主窗口的相对位置偏移
SnapEdge models.SnapEdge // 吸附的边缘类型
LastPos models.WindowPosition // 上一次记录的窗口位置
MoveTime time.Time // 上次移动时间,用于判断移动速度
}
// NewWindowSnapService 创建新的窗口吸附服务实例
func NewWindowSnapService(logger *log.LogService, configService *ConfigService) *WindowSnapService {
if logger == nil {
logger = log.New()
}
// 从配置获取窗口吸附设置
config, err := configService.GetConfig()
snapEnabled := true // 默认启用
snapThreshold := 15 // 默认阈值
if err == nil {
snapEnabled = config.General.EnableWindowSnap
snapThreshold = config.General.SnapThreshold
}
return &WindowSnapService{
logger: logger,
configService: configService,
snapThreshold: snapThreshold,
snapEnabled: snapEnabled,
managedWindows: make(map[int64]*SnapWindowInfo),
changedWindows: make(map[int64]bool),
skipFrames: 0,
}
}
// SetAppReferences 设置应用和主窗口引用
func (wss *WindowSnapService) SetAppReferences(app *application.App, mainWindow *application.WebviewWindow) {
wss.app = app
wss.mainWindow = mainWindow
// 初始化上下文用于控制goroutine的生命周期
wss.ctx, wss.cancel = context.WithCancel(context.Background())
// 启动窗口吸附监听器
wss.StartWindowSnapMonitor()
}
// RegisterWindow 注册需要吸附管理的窗口
func (wss *WindowSnapService) RegisterWindow(documentID int64, window *application.WebviewWindow, title string) {
wss.mu.Lock()
defer wss.mu.Unlock()
wss.managedWindows[documentID] = &SnapWindowInfo{
Window: window,
DocumentID: documentID,
Title: title,
IsSnapped: false,
SnapOffset: models.SnapPosition{X: 0, Y: 0},
SnapEdge: models.SnapEdgeNone,
LastPos: models.WindowPosition{X: 0, Y: 0},
MoveTime: time.Now(),
}
}
// UnregisterWindow 取消注册窗口
func (wss *WindowSnapService) UnregisterWindow(documentID int64) {
wss.mu.Lock()
defer wss.mu.Unlock()
delete(wss.managedWindows, documentID)
delete(wss.changedWindows, documentID)
}
// SetSnapEnabled 设置是否启用窗口吸附
func (wss *WindowSnapService) SetSnapEnabled(enabled bool) {
wss.mu.Lock()
defer wss.mu.Unlock()
if wss.snapEnabled == enabled {
return
}
wss.snapEnabled = enabled
// 如果禁用吸附,解除所有吸附窗口
if !enabled {
for _, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
}
}
// 停止定时器
if wss.snapTicker != nil {
wss.snapTicker.Stop()
wss.snapTicker = nil
}
} else if wss.snapTicker == nil && wss.app != nil {
// 重新启动定时器
wss.StartWindowSnapMonitor()
}
}
// SetSnapThreshold 设置窗口吸附阈值
func (wss *WindowSnapService) SetSnapThreshold(threshold int) {
wss.mu.Lock()
defer wss.mu.Unlock()
if threshold <= 0 || wss.snapThreshold == threshold {
return
}
wss.snapThreshold = threshold
}
// OnWindowSnapConfigChanged 处理窗口吸附配置变更
func (wss *WindowSnapService) OnWindowSnapConfigChanged(enabled bool, threshold int) error {
wss.SetSnapEnabled(enabled)
wss.SetSnapThreshold(threshold)
return nil
}
// StartWindowSnapMonitor 启动窗口吸附监听器
func (wss *WindowSnapService) StartWindowSnapMonitor() {
// 如果定时器已存在,先停止它
if wss.snapTicker != nil {
wss.snapTicker.Stop()
}
// 只有在吸附功能启用时才启动监听器
if !wss.snapEnabled {
return
}
// 创建新的定时器20fps 以优化性能
wss.snapTicker = time.NewTicker(50 * time.Millisecond)
// 启动goroutine持续监听窗口位置
go func() {
for {
select {
case <-wss.snapTicker.C:
wss.checkAndApplySnapping()
case <-wss.ctx.Done():
// 上下文取消时停止goroutine
return
}
}
}()
}
// checkAndApplySnapping 检测并应用窗口吸附(性能优化版本)
func (wss *WindowSnapService) checkAndApplySnapping() {
if !wss.snapEnabled {
return
}
// 性能优化每3帧执行一次完整检测其他时间只处理变化的窗口
wss.skipFrames++
fullCheck := wss.skipFrames%3 == 0
wss.mu.Lock()
defer wss.mu.Unlock()
// 检查主窗口是否存在且可见
if wss.mainWindow == nil || !wss.isMainWindowAvailable() {
// 主窗口不可用,解除所有吸附
for _, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
}
}
// 清空变化记录
wss.changedWindows = make(map[int64]bool)
return
}
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
// 检查主窗口是否移动了
mainWindowMoved := mainPos.X != wss.lastMainWindowPos.X || mainPos.Y != wss.lastMainWindowPos.Y
if mainWindowMoved {
wss.lastMainWindowPos = mainPos
// 主窗口移动了,标记所有吸附的窗口为变化
for documentID, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
wss.changedWindows[documentID] = true
}
}
}
for documentID, windowInfo := range wss.managedWindows {
currentPos, _ := wss.getWindowPosition(windowInfo.Window)
// 检查窗口是否移动了
hasMoved := currentPos.X != windowInfo.LastPos.X || currentPos.Y != windowInfo.LastPos.Y
if hasMoved {
windowInfo.MoveTime = time.Now()
windowInfo.LastPos = currentPos
wss.changedWindows[documentID] = true
}
// 性能优化:只处理变化的窗口或进行完整检查时
if !fullCheck && !wss.changedWindows[documentID] {
continue
}
if windowInfo.IsSnapped {
// 窗口已吸附,检查是否需要更新位置或解除吸附
wss.handleSnappedWindow(windowInfo, mainPos, currentPos)
} else {
// 窗口未吸附,检查是否应该吸附
wss.handleUnsnappedWindow(windowInfo)
}
// 处理完成后清除变化标记
delete(wss.changedWindows, documentID)
}
}
// isMainWindowAvailable 检查主窗口是否可用
func (wss *WindowSnapService) isMainWindowAvailable() bool {
if wss.mainWindow == nil {
return false
}
// 检查主窗口是否可见和正常状态
return wss.mainWindow.IsVisible()
}
// handleSnappedWindow 处理已吸附的窗口
func (wss *WindowSnapService) handleSnappedWindow(windowInfo *SnapWindowInfo, mainPos models.WindowPosition, currentPos models.WindowPosition) {
// 计算预期位置基于主窗口的新位置
expectedX := mainPos.X + windowInfo.SnapOffset.X
expectedY := mainPos.Y + windowInfo.SnapOffset.Y
// 计算当前位置与预期位置的距离
distanceX := math.Abs(float64(currentPos.X - expectedX))
distanceY := math.Abs(float64(currentPos.Y - expectedY))
maxDistance := math.Max(distanceX, distanceY)
// 检测是否为用户主动拖拽:如果窗口移动幅度超过阈值且是最近移动的
userDragThreshold := float64(wss.snapThreshold)
isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < 100*time.Millisecond
if isUserDrag {
// 用户主动拖拽,立即解除吸附
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
return
}
// 对于主窗口移动导致的位置变化,立即跟随且不解除吸附
if maxDistance > 0 {
// 直接调整到预期位置,不使用平滑移动以提高响应速度
windowInfo.Window.SetPosition(expectedX, expectedY)
windowInfo.LastPos = models.WindowPosition{X: expectedX, Y: expectedY}
}
}
// handleUnsnappedWindow 处理未吸附的窗口
func (wss *WindowSnapService) handleUnsnappedWindow(windowInfo *SnapWindowInfo) {
// 检查是否应该吸附到主窗口
should, snapEdge := wss.shouldSnapToMainWindow(windowInfo)
if should {
// 获取主窗口位置用于计算偏移量
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
// 设置吸附状态
windowInfo.IsSnapped = true
windowInfo.SnapEdge = snapEdge
// 执行即时吸附,产生明显的吸附效果
wss.snapWindowToMainWindow(windowInfo, snapEdge)
// 重新获取吸附后的位置来计算偏移量
newPos, _ := wss.getWindowPosition(windowInfo.Window)
windowInfo.SnapOffset.X = newPos.X - mainPos.X
windowInfo.SnapOffset.Y = newPos.Y - mainPos.Y
windowInfo.LastPos = newPos
}
}
// getWindowPosition 获取窗口的位置
func (wss *WindowSnapService) getWindowPosition(window *application.WebviewWindow) (models.WindowPosition, bool) {
x, y := window.Position()
return models.WindowPosition{X: x, Y: y}, true
}
// shouldSnapToMainWindow 检查窗口是否应该吸附到主窗口(支持角落吸附)
func (wss *WindowSnapService) shouldSnapToMainWindow(windowInfo *SnapWindowInfo) (bool, models.SnapEdge) {
// 降低防抖时间,提高吸附响应速度
if time.Since(windowInfo.MoveTime) < 50*time.Millisecond {
return false, models.SnapEdgeNone
}
// 获取两个窗口的位置
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
windowPos, _ := wss.getWindowPosition(windowInfo.Window)
// 获取主窗口尺寸
mainWidth, mainHeight := wss.mainWindow.Size()
// 获取子窗口尺寸
windowWidth, windowHeight := windowInfo.Window.Size()
// 计算各个边缘的距离
threshold := float64(wss.snapThreshold)
cornerThreshold := threshold * 1.5 // 角落吸附需要更大的阈值
// 主窗口的四个边界
mainLeft := mainPos.X
mainTop := mainPos.Y
mainRight := mainPos.X + mainWidth
mainBottom := mainPos.Y + mainHeight
// 子窗口的四个边界
windowLeft := windowPos.X
windowTop := windowPos.Y
windowRight := windowPos.X + windowWidth
windowBottom := windowPos.Y + windowHeight
// 存储每个边缘的吸附信息
type snapCandidate struct {
edge models.SnapEdge
distance float64
overlap float64 // 重叠度,用于优先级判断
isCorner bool // 是否为角落吸附
}
var candidates []snapCandidate
// ==== 检查角落吸附(优先级最高) ====
// 1. 右上角 (SnapEdgeTopRight)
distToTopRight := math.Sqrt(math.Pow(float64(mainRight-windowLeft), 2) + math.Pow(float64(mainTop-windowBottom), 2))
if distToTopRight <= cornerThreshold {
// 检查是否接近右上角区域
horizDist := math.Abs(float64(mainRight - windowLeft))
vertDist := math.Abs(float64(mainTop - windowBottom))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeTopRight, distToTopRight, 100, true})
}
}
// 2. 右下角 (SnapEdgeBottomRight)
distToBottomRight := math.Sqrt(math.Pow(float64(mainRight-windowLeft), 2) + math.Pow(float64(mainBottom-windowTop), 2))
if distToBottomRight <= cornerThreshold {
horizDist := math.Abs(float64(mainRight - windowLeft))
vertDist := math.Abs(float64(mainBottom - windowTop))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeBottomRight, distToBottomRight, 100, true})
}
}
// 3. 左下角 (SnapEdgeBottomLeft)
distToBottomLeft := math.Sqrt(math.Pow(float64(mainLeft-windowRight), 2) + math.Pow(float64(mainBottom-windowTop), 2))
if distToBottomLeft <= cornerThreshold {
horizDist := math.Abs(float64(mainLeft - windowRight))
vertDist := math.Abs(float64(mainBottom - windowTop))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeBottomLeft, distToBottomLeft, 100, true})
}
}
// 4. 左上角 (SnapEdgeTopLeft)
distToTopLeft := math.Sqrt(math.Pow(float64(mainLeft-windowRight), 2) + math.Pow(float64(mainTop-windowBottom), 2))
if distToTopLeft <= cornerThreshold {
horizDist := math.Abs(float64(mainLeft - windowRight))
vertDist := math.Abs(float64(mainTop - windowBottom))
if horizDist <= cornerThreshold && vertDist <= cornerThreshold {
candidates = append(candidates, snapCandidate{models.SnapEdgeTopLeft, distToTopLeft, 100, true})
}
}
// ==== 检查边缘吸附(只在没有角落吸附时检查) ====
if len(candidates) == 0 {
// 1. 吸附到主窗口右侧
distToRight := math.Abs(float64(mainRight - windowLeft))
if distToRight <= threshold {
// 计算垂直重叠
overlapTop := math.Max(float64(mainTop), float64(windowTop))
overlapBottom := math.Min(float64(mainBottom), float64(windowBottom))
verticalOverlap := math.Max(0, overlapBottom-overlapTop)
candidates = append(candidates, snapCandidate{models.SnapEdgeRight, distToRight, verticalOverlap, false})
}
// 2. 吸附到主窗口左侧
distToLeft := math.Abs(float64(mainLeft - windowRight))
if distToLeft <= threshold {
// 计算垂直重叠
overlapTop := math.Max(float64(mainTop), float64(windowTop))
overlapBottom := math.Min(float64(mainBottom), float64(windowBottom))
verticalOverlap := math.Max(0, overlapBottom-overlapTop)
candidates = append(candidates, snapCandidate{models.SnapEdgeLeft, distToLeft, verticalOverlap, false})
}
// 3. 吸附到主窗口底部
distToBottom := math.Abs(float64(mainBottom - windowTop))
if distToBottom <= threshold {
// 计算水平重叠
overlapLeft := math.Max(float64(mainLeft), float64(windowLeft))
overlapRight := math.Min(float64(mainRight), float64(windowRight))
horizontalOverlap := math.Max(0, overlapRight-overlapLeft)
candidates = append(candidates, snapCandidate{models.SnapEdgeBottom, distToBottom, horizontalOverlap, false})
}
// 4. 吸附到主窗口顶部
distToTop := math.Abs(float64(mainTop - windowBottom))
if distToTop <= threshold {
// 计算水平重叠
overlapLeft := math.Max(float64(mainLeft), float64(windowLeft))
overlapRight := math.Min(float64(mainRight), float64(windowRight))
horizontalOverlap := math.Max(0, overlapRight-overlapLeft)
candidates = append(candidates, snapCandidate{models.SnapEdgeTop, distToTop, horizontalOverlap, false})
}
}
// 如果没有候选,不吸附
if len(candidates) == 0 {
return false, models.SnapEdgeNone
}
// 选择最佳吸附位置:角落吸附优先,其次考虑重叠度,最后考虑距离
bestCandidate := candidates[0]
for _, candidate := range candidates[1:] {
// 角落吸附优先级最高
if candidate.isCorner && !bestCandidate.isCorner {
bestCandidate = candidate
} else if bestCandidate.isCorner && !candidate.isCorner {
// 继续使用当前的角落吸附
continue
} else {
// 同类型的吸附,比较重叠度和距离
if math.Abs(candidate.overlap-bestCandidate.overlap) < 10 {
if candidate.distance < bestCandidate.distance {
bestCandidate = candidate
}
} else if candidate.overlap > bestCandidate.overlap {
// 重叠度更高的优先
bestCandidate = candidate
}
}
}
return true, bestCandidate.edge
}
// snapWindowToMainWindow 将窗口精确吸附到主窗口边缘(支持角落吸附)
func (wss *WindowSnapService) snapWindowToMainWindow(windowInfo *SnapWindowInfo, snapEdge models.SnapEdge) {
// 获取主窗口位置和尺寸
mainPos, _ := wss.getWindowPosition(wss.mainWindow)
mainWidth, mainHeight := wss.mainWindow.Size()
// 获取子窗口位置和尺寸
windowPos, _ := wss.getWindowPosition(windowInfo.Window)
windowWidth, windowHeight := windowInfo.Window.Size()
// 计算目标位置
var targetX, targetY int
switch snapEdge {
case models.SnapEdgeRight:
// 吸附到主窗口右侧
targetX = mainPos.X + mainWidth
targetY = windowPos.Y // 保持当前 Y 位置
// 如果超出主窗口范围,调整到边界
if targetY < mainPos.Y {
targetY = mainPos.Y
} else if targetY+windowHeight > mainPos.Y+mainHeight {
targetY = mainPos.Y + mainHeight - windowHeight
}
case models.SnapEdgeLeft:
// 吸附到主窗口左侧
targetX = mainPos.X - windowWidth
targetY = windowPos.Y // 保持当前 Y 位置
// 如果超出主窗口范围,调整到边界
if targetY < mainPos.Y {
targetY = mainPos.Y
} else if targetY+windowHeight > mainPos.Y+mainHeight {
targetY = mainPos.Y + mainHeight - windowHeight
}
case models.SnapEdgeBottom:
// 吸附到主窗口底部
targetX = windowPos.X // 保持当前 X 位置
targetY = mainPos.Y + mainHeight
// 如果超出主窗口范围,调整到边界
if targetX < mainPos.X {
targetX = mainPos.X
} else if targetX+windowWidth > mainPos.X+mainWidth {
targetX = mainPos.X + mainWidth - windowWidth
}
case models.SnapEdgeTop:
// 吸附到主窗口顶部
targetX = windowPos.X // 保持当前 X 位置
targetY = mainPos.Y - windowHeight
// 如果超出主窗口范围,调整到边界
if targetX < mainPos.X {
targetX = mainPos.X
} else if targetX+windowWidth > mainPos.X+mainWidth {
targetX = mainPos.X + mainWidth - windowWidth
}
// ==== 角落吸附 ====
case models.SnapEdgeTopRight:
// 吸附到右上角
targetX = mainPos.X + mainWidth
targetY = mainPos.Y - windowHeight
case models.SnapEdgeBottomRight:
// 吸附到右下角
targetX = mainPos.X + mainWidth
targetY = mainPos.Y + mainHeight
case models.SnapEdgeBottomLeft:
// 吸附到左下角
targetX = mainPos.X - windowWidth
targetY = mainPos.Y + mainHeight
case models.SnapEdgeTopLeft:
// 吸附到左上角
targetX = mainPos.X - windowWidth
targetY = mainPos.Y - windowHeight
default:
// 不应该到达这里
return
}
// 直接移动到目标位置,不使用平滑过渡以产生明显的吸附效果
windowInfo.Window.SetPosition(targetX, targetY)
// 更新窗口信息
windowInfo.SnapEdge = snapEdge
windowInfo.LastPos = models.WindowPosition{X: targetX, Y: targetY}
}
// Cleanup 清理资源
func (wss *WindowSnapService) Cleanup() {
// 如果有取消函数调用它来停止所有goroutine
if wss.cancel != nil {
wss.cancel()
}
// 停止定时器
if wss.snapTicker != nil {
wss.snapTicker.Stop()
wss.snapTicker = nil
}
}
// ServiceShutdown 实现服务关闭接口
func (wss *WindowSnapService) ServiceShutdown() error {
wss.Cleanup()
return nil
}