From a5af70623fb12577c6b592bdf05c386eb3f0ad72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E6=88=8E=E6=B0=8F?= Date: Thu, 7 May 2026 10:14:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=BE=A9=E9=82=8A?= =?UTF-8?q?=E7=95=8C=E9=87=8D=E5=AE=9A=E4=BD=8D=E5=B0=8E=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E9=8C=AF=E4=BD=8D=E5=8F=8A=E8=B6=85=E5=A4=A7?= =?UTF-8?q?=E5=AD=97=E8=99=9F=E6=8E=92=E7=89=88=E6=88=AA=E6=96=B7=E5=95=8F?= =?UTF-8?q?=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交解決了在超大字號及窗口貼邊時,出現的圖形渲染脫節和排版滾動異常問題, 包含三個層面的修復: 1. 圖形渲染層(修復背景色塊與文字脫節): 在 `SquirrelView.draw(_:)` 中,將所有依賴 `dirtyRect` 的幾何計算與座 標系矩陣翻轉(AffineTransform)替換為 `self.bounds`。 原因:`dirtyRect` 僅代表「局部重繪區域」(例如窗口靠近屏幕邊界被擠壓 時,系統經常只重繪視圖的一半)。用局部高度去計算整體視圖的座標翻轉, 會導致背景路徑飛出真實範圍。改用完整的 `bounds` 可確保圖形座標域永遠 和文字視圖貼合。 2. 測量層(修復超大字號/行距導致的頂部被切): 在計算 `contentRect` 時,強制確保起點涵蓋 `(0, 0)` 座標,使用 `min(0, x0)` 和 `min(0, y0)`。 原因:TextKit 2 針對超大字號及巨大段間距進行排版時,其 Typographic Bounds 經常會出現負座標(Ascender 超出基線)。如果不將負起點納入計算 並對齊到 0,會導致返回的總高度偏小,最終使面板切掉候選字的上下邊緣。 3. 排版與防滾動層(修復文字上下偏離高亮區域): 在 `SquirrelPanel.update` 賦值新文本後,立即寫入 `textContainer` 最大 尺寸並調用 `ensureLayout` 強制同步排版,最後調用 `scrollToBeginningOfDocument(nil)`。 原因:當追加輸入碼導致折行超高時,`NSTextView` 的原生行為是自動向下滾 動以保證插入點可見,這會把第一行字頂出上邊界。這裡強制重置視圖的滾動 位置,確保文字頂部永遠和視圖頂部對齊。 --- sources/SquirrelPanel.swift | 12 ++++++++++-- sources/SquirrelView.swift | 9 +++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 6713166bf..37506fd96 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -283,6 +283,16 @@ final class SquirrelPanel: NSPanel { // text done! view.textView.textContentStorage?.attributedString = text view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) + + // 強制 TextKit 2 立即同步佈局,確保後續計算窗口和高亮背景時,拿到的是折行後的真實尺寸 + let textWidth = maxTextWidth() + let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2 + view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight) + view.textLayoutManager.ensureLayout(for: view.textLayoutManager.documentRange) + + // 重置 NSTextView 內部視圖滾動位置,防止因爲折行超高導致自動滾動到文末(第一行溢出) + view.textView.scrollToBeginningOfDocument(nil) + view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage) show() } @@ -346,8 +356,6 @@ private extension SquirrelPanel { return maxWidth } - // Get the window size, the windows will be the dirtyRect in - // SquirrelView.drawRect // swiftlint:disable:next cyclomatic_complexity func show() { currentScreen() diff --git a/sources/SquirrelView.swift b/sources/SquirrelView.swift index 39529b7b2..def4ab2b2 100644 --- a/sources/SquirrelView.swift +++ b/sources/SquirrelView.swift @@ -99,7 +99,8 @@ final class SquirrelView: NSView { y1 = max(rect.maxY, y1) } } - return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) + if x1 == -CGFloat.infinity { return .zero } + return NSRect(x: min(0, x0), y: min(0, y0), width: x1 - min(0, x0), height: y1 - min(0, y0)) } // Get the rectangle containing the range of text, will first convert to glyph range, expensive to calculate func contentRect(range: NSTextRange) -> NSRect { @@ -137,7 +138,7 @@ final class SquirrelView: NSView { var highlightedPreeditPath: CGMutablePath? let theme = currentTheme - var containingRect = dirtyRect + var containingRect = self.bounds containingRect.size.width -= theme.pagingOffset let backgroundRect = containingRect @@ -289,13 +290,13 @@ final class SquirrelView: NSView { } panelLayer.setAffineTransform(CGAffineTransform(translationX: theme.pagingOffset, y: 0)) let panelPath = CGMutablePath() - panelPath.addPath(backgroundPath!, transform: panelLayer.affineTransform().scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height)) + panelPath.addPath(backgroundPath!, transform: panelLayer.affineTransform().scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -self.bounds.height)) let (pagingLayer, downPath, upPath) = pagingLayer(theme: theme, preeditRect: preeditRect) if let sublayers = pagingLayer.sublayers, !sublayers.isEmpty { self.layer?.addSublayer(pagingLayer) } - let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height) + let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -self.bounds.height) if let downPath { panelPath.addPath(downPath, transform: flipTransform) self.downPath = downPath.copy() From 6d0b748a3d0278825a39884a9af3131d15a91b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E6=88=8E=E6=B0=8F?= Date: Thu, 7 May 2026 10:49:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20=E5=85=A8=E5=B1=8F=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 內容超出屏幕空間時,自動縮放文字,候選窗居中。 --- sources/SquirrelPanel.swift | 164 +++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 57 deletions(-) diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 37506fd96..0c755d264 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -367,93 +367,143 @@ private extension SquirrelPanel { self.appearance = NSAppearance(named: .aqua) } - // Break line if the text is too long, based on screen size. + view.textView.textContainerInset = theme.edgeInset + let textWidth = maxTextWidth() - let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2 - view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight) + // 高度設置爲無窮大,放開限制,讓超大文本完全測量出真實自然高度 + view.textContainer.size = NSSize(width: textWidth, height: .greatestFiniteMagnitude) + + // 嚴禁 NSTextView 自動把 textContainer 縮小到當前的視圖寬度,防止死循環與文字消失 + view.textContainer.widthTracksTextView = false + view.textContainer.heightTracksTextView = false + + // 強制完成排版,並歸零 bounds + view.textLayoutManager.ensureLayout(for: view.textLayoutManager.documentRange) + view.textView.bounds.origin = .zero - var panelRect = NSRect.zero - // in vertical mode, the width and height are interchanged var contentRect = view.contentRect - if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) || - (vertical && position.minX + max(contentRect.width, maxHeight) + theme.edgeInset.width * 2 > screenRect.maxX) { - if contentRect.width >= maxHeight { - maxHeight = contentRect.width - } else { - contentRect.size.width = maxHeight - view.textContainer.size = NSSize(width: maxHeight, height: maxTextHeight) - } - } + // 計算出「不加限制時」需要的自然面板巨型尺寸 + var naturalPanelSize = NSSize.zero if vertical { - panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.height + theme.edgeInset.height * 2), - height: min(0.95 * screenRect.height, contentRect.width + theme.edgeInset.width * 2) + theme.pagingOffset) - - // To avoid jumping up and down while typing, use the lower screen when - // typing on upper, and vice versa - if position.midY / screenRect.height >= 0.5 { - panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset - } else { - panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight - } - // Make the first candidate fixed at the left of cursor - panelRect.origin.x = position.minX - panelRect.width - SquirrelTheme.offsetHeight - if view.preeditRange.length > 0, let preeditTextRange = view.convert(range: view.preeditRange) { - let preeditRect = view.contentRect(range: preeditTextRange) - panelRect.origin.x += preeditRect.height + theme.edgeInset.width - } + naturalPanelSize.width = contentRect.height + theme.edgeInset.height * 2 + naturalPanelSize.height = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset } else { - panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.width + theme.edgeInset.width * 2), - height: min(0.95 * screenRect.height, contentRect.height + theme.edgeInset.height * 2)) - panelRect.size.width += theme.pagingOffset - panelRect.origin = NSPoint(x: position.minX - theme.pagingOffset, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height) - } - if panelRect.maxX > screenRect.maxX { - panelRect.origin.x = screenRect.maxX - panelRect.width - } - if panelRect.minX < screenRect.minX { - panelRect.origin.x = screenRect.minX + naturalPanelSize.width = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset + naturalPanelSize.height = contentRect.height + theme.edgeInset.height * 2 } - if panelRect.minY < screenRect.minY { + + // 屏幕最大可用範圍(留白 5%) + let maxAllowedWidth = screenRect.width * 0.95 + let maxAllowedHeight = screenRect.height * 0.95 + + // 判斷是否需要觸發「全屏模式」 + let requiresFullScreen = naturalPanelSize.width > maxAllowedWidth || naturalPanelSize.height > maxAllowedHeight + + var panelRect = NSRect.zero + + if requiresFullScreen { + // --- 全屏縮放模式 --- + let scaleX = maxAllowedWidth / naturalPanelSize.width + let scaleY = maxAllowedHeight / naturalPanelSize.height + let scale = min(scaleX, scaleY) // 保持等比縮小 + + // 窗口實際物理大小被縮小 + panelRect.size = NSSize(width: naturalPanelSize.width * scale, height: naturalPanelSize.height * scale) + + // 屏幕正中央對齊 + panelRect.origin = NSPoint( + x: screenRect.minX + (screenRect.width - panelRect.width) / 2, + y: screenRect.minY + (screenRect.height - panelRect.height) / 2 + ) + + maxHeight = 0 // 重置記憶尺寸緩存 + } else { + // --- 常規跟隨光標模式 --- + // Apply memorizeSize + if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) || + (vertical && position.minX + max(contentRect.width, maxHeight) + theme.edgeInset.width * 2 > screenRect.maxX) { + if contentRect.width >= maxHeight { + maxHeight = contentRect.width + } else { + contentRect.size.width = maxHeight + // 需要根據記憶寬度更新自然尺寸 + if vertical { + naturalPanelSize.height = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset + } else { + naturalPanelSize.width = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset + } + } + } + + panelRect.size = naturalPanelSize + if vertical { - panelRect.origin.y = screenRect.minY + // To avoid jumping up and down while typing + if position.midY / screenRect.height >= 0.5 { + panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset + } else { + panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight + } + panelRect.origin.x = position.minX - panelRect.width - SquirrelTheme.offsetHeight + if view.preeditRange.length > 0, let preeditTextRange = view.convert(range: view.preeditRange) { + let preeditRect = view.contentRect(range: preeditTextRange) + panelRect.origin.x += preeditRect.height + theme.edgeInset.width + } } else { - panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight + panelRect.origin = NSPoint(x: position.minX - theme.pagingOffset, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height) } + + // 常規模式下的邊界限制 + if panelRect.maxX > screenRect.maxX { panelRect.origin.x = screenRect.maxX - panelRect.width } + if panelRect.minX < screenRect.minX { panelRect.origin.x = screenRect.minX } + if panelRect.minY < screenRect.minY { + if vertical { panelRect.origin.y = screenRect.minY } else { panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight } + } + if panelRect.maxY > screenRect.maxY { panelRect.origin.y = screenRect.maxY - panelRect.height } + if panelRect.minY < screenRect.minY { panelRect.origin.y = screenRect.minY } } - if panelRect.maxY > screenRect.maxY { - panelRect.origin.y = screenRect.maxY - panelRect.height - } - if panelRect.minY < screenRect.minY { - panelRect.origin.y = screenRect.minY - } + self.setFrame(panelRect, display: true) + // contentView 的 frame 決定了它在窗口上的物理大小; + // contentView 的 bounds 決定了它內部的投影座標系。 + // 將 bounds 設爲 naturalPanelSize(自然尺寸),視圖內部畫的一切東西就會自動被縮小到窗口(frame)裏 + contentView!.frame = NSRect(origin: .zero, size: panelRect.size) + contentView!.bounds = NSRect(origin: .zero, size: naturalPanelSize) + // rotate the view, the core in vertical mode! if vertical { contentView!.boundsRotation = -90 - contentView!.setBoundsOrigin(NSPoint(x: 0, y: panelRect.width)) + contentView!.setBoundsOrigin(NSPoint(x: 0, y: naturalPanelSize.width)) } else { contentView!.boundsRotation = 0 contentView!.setBoundsOrigin(.zero) } + view.textView.boundsRotation = 0 view.textView.setBoundsOrigin(.zero) - view.frame = contentView!.bounds - view.textView.frame = contentView!.bounds - view.textView.frame.size.width -= theme.pagingOffset - view.textView.frame.origin.x += theme.pagingOffset - view.textView.textContainerInset = theme.edgeInset + // 下層組件必須讀取 contentView 旋轉後的真實 bounds + // (在 vertical 模式下,Cocoa 會自動將 bounds origin 偏移並交換長寬,確保內容在可見範圍內) + let subviewFrame = contentView!.bounds + view.frame = subviewFrame + + var textFrame = subviewFrame + textFrame.size.width -= theme.pagingOffset + textFrame.origin.x += theme.pagingOffset + view.textView.frame = textFrame if theme.translucency { - back.frame = contentView!.bounds - back.frame.size.width += theme.pagingOffset + var backFrame = subviewFrame + backFrame.size.width += theme.pagingOffset + back.frame = backFrame back.appearance = NSApp.effectiveAppearance back.isHidden = false } else { back.isHidden = true } + alphaValue = theme.alpha invalidateShadow() orderFront(nil) From d47b984a3e138c12b3e1bb90074f05d0574476ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E6=88=8E=E6=B0=8F?= Date: Thu, 7 May 2026 16:51:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(ui):=20=E5=85=A8=E5=B1=8F=E6=99=82?= =?UTF-8?q?=E9=9A=A8=E6=96=87=E5=AD=97=E7=B8=AE=E6=94=BE=E5=8B=95=E6=85=8B?= =?UTF-8?q?=E8=AA=BF=E6=95=B4=E8=A1=8C=E7=9A=84=E9=95=B7=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sources/SquirrelPanel.swift | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 0c755d264..4d40ef140 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -369,7 +369,7 @@ private extension SquirrelPanel { view.textView.textContainerInset = theme.edgeInset - let textWidth = maxTextWidth() + var textWidth = maxTextWidth() // 高度設置爲無窮大,放開限制,讓超大文本完全測量出真實自然高度 view.textContainer.size = NSSize(width: textWidth, height: .greatestFiniteMagnitude) @@ -400,6 +400,39 @@ private extension SquirrelPanel { // 判斷是否需要觸發「全屏模式」 let requiresFullScreen = naturalPanelSize.width > maxAllowedWidth || naturalPanelSize.height > maxAllowedHeight + if requiresFullScreen { + // --- 動態長寬比優化 --- + // 解決全屏縮放時,等比縮小導致「行長物理縮減、窗口變窄」的空間浪費問題 + let area = contentRect.width * contentRect.height + let screenRatio = maxAllowedWidth / maxAllowedHeight + + let optimalTextWidth: CGFloat + if vertical { + // 直排:自然寬度=高,自然高度=寬。算出完美契合螢幕比例的虛擬行長 + optimalTextWidth = sqrt(area / screenRatio) + } else { + // 橫排:算出完美契合螢幕比例的虛擬行長 + optimalTextWidth = sqrt(area * screenRatio) + } + + // 如果最佳行長大於原本設定的限制,就放開限制進行第二次完美排版 + if optimalTextWidth > textWidth { + textWidth = optimalTextWidth + view.textContainer.size = NSSize(width: textWidth, height: .greatestFiniteMagnitude) + view.textLayoutManager.ensureLayout(for: view.textLayoutManager.documentRange) + + contentRect = view.contentRect + + if vertical { + naturalPanelSize.width = contentRect.height + theme.edgeInset.height * 2 + naturalPanelSize.height = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset + } else { + naturalPanelSize.width = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset + naturalPanelSize.height = contentRect.height + theme.edgeInset.height * 2 + } + } + } + var panelRect = NSRect.zero if requiresFullScreen {