diff --git a/Source/Modules/IMEModules/ctlInputMethod.swift b/Source/Modules/IMEModules/ctlInputMethod.swift index 353b8650..e52e7e69 100644 --- a/Source/Modules/IMEModules/ctlInputMethod.swift +++ b/Source/Modules/IMEModules/ctlInputMethod.swift @@ -32,8 +32,8 @@ private let kMinKeyLabelSize: CGFloat = 10 private var ctlCandidateCurrent: ctlCandidate? extension ctlCandidate { - fileprivate static let horizontal = ctlCandidateHorizontal() - fileprivate static let vertical = ctlCandidateVertical() + static let horizontal = ctlCandidateUniversal(.horizontal) + static let vertical = ctlCandidateUniversal(.vertical) } @objc(ctlInputMethod) diff --git a/Source/UI/CandidateUI/ctlCandidate.swift b/Source/UI/CandidateUI/ctlCandidate.swift index 9fb076f5..1bd93c26 100644 --- a/Source/UI/CandidateUI/ctlCandidate.swift +++ b/Source/UI/CandidateUI/ctlCandidate.swift @@ -47,6 +47,11 @@ public protocol ctlCandidateDelegate: AnyObject { } public class ctlCandidate: NSWindowController { + public enum Layout { + case horizontal + case vertical + } + public var currentLayout: Layout = .horizontal public weak var delegate: ctlCandidateDelegate? { didSet { reloadData() diff --git a/Source/UI/CandidateUI/ctlCandidateUniversal.swift b/Source/UI/CandidateUI/ctlCandidateUniversal.swift new file mode 100644 index 00000000..a622265c --- /dev/null +++ b/Source/UI/CandidateUI/ctlCandidateUniversal.swift @@ -0,0 +1,602 @@ +// Copyright (c) 2011 and onwards The OpenVanilla Project (MIT License). +// All possible vChewing-specific modifications are of: +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +/* +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +2. No trademark license is granted to use the trade names, trademarks, service +marks, or product names of Contributor, except as required to fulfill notice +requirements above. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import Cocoa + +// 將之前 Zonble 重寫的 Voltaire 選字窗隔的橫向版本與縱向版本合併到同一個型別實體內。 + +private class vwrCandidateUniversal: NSView { + var highlightedIndex: Int = 0 { didSet { highlightedIndex = max(highlightedIndex, 0) } } + var action: Selector? + weak var target: AnyObject? + var isVerticalLayout: Bool = false + + private var keyLabels: [String] = [] + private var displayedCandidates: [String] = [] + private var dispCandidatesWithLabels: [String] = [] + private var keyLabelHeight: CGFloat = 0 + private var keyLabelWidth: CGFloat = 0 + private var candidateTextHeight: CGFloat = 0 + private var cellPadding: CGFloat = 0 + private var keyLabelAttrDict: [NSAttributedString.Key: AnyObject] = [:] + private var candidateAttrDict: [NSAttributedString.Key: AnyObject] = [:] + private var candidateWithLabelAttrDict: [NSAttributedString.Key: AnyObject] = [:] + private var windowWidth: CGFloat = 0 // 縱排專用 + private var elementWidths: [CGFloat] = [] + private var elementHeights: [CGFloat] = [] // 縱排專用 + private var trackingHighlightedIndex: Int = .max { + didSet { trackingHighlightedIndex = max(trackingHighlightedIndex, 0) } + } + + override var isFlipped: Bool { + true + } + + var sizeForView: NSSize { + var result = NSSize.zero + + if !elementWidths.isEmpty { + switch isVerticalLayout { + case true: + result.width = windowWidth + result.height = elementHeights.reduce(0, +) + case false: + result.width = elementWidths.reduce(0, +) + CGFloat(elementWidths.count) + result.height = candidateTextHeight + cellPadding + } + } + return result + } + + @objc(setKeyLabels:displayedCandidates:) + func set(keyLabels labels: [String], displayedCandidates candidates: [String]) { + let count = min(labels.count, candidates.count) + keyLabels = Array(labels[0.. Int { + let location = convert(event.locationInWindow, to: nil) + if !bounds.contains(location) { + return NSNotFound + } + switch isVerticalLayout { + case true: + do { + var accuHeight: CGFloat = 0.0 + for (index, elementHeight) in elementHeights.enumerated() { + let currentHeight = elementHeight + + if location.y >= accuHeight, location.y <= accuHeight + currentHeight { + return index + } + accuHeight += currentHeight + } + } + case false: + do { + var accuWidth: CGFloat = 0.0 + for (index, elementWidth) in elementWidths.enumerated() { + let currentWidth = elementWidth + + if location.x >= accuWidth, location.x <= accuWidth + currentWidth { + return index + } + accuWidth += currentWidth + 1.0 + } + } + } + return NSNotFound + } + + override func mouseUp(with event: NSEvent) { + trackingHighlightedIndex = highlightedIndex + let newIndex = findHitIndex(event: event) + guard newIndex != NSNotFound else { + return + } + highlightedIndex = newIndex + setNeedsDisplay(bounds) + } + + override func mouseDown(with event: NSEvent) { + let newIndex = findHitIndex(event: event) + guard newIndex != NSNotFound else { + return + } + var triggerAction = false + if newIndex == highlightedIndex { + triggerAction = true + } else { + highlightedIndex = trackingHighlightedIndex + } + + trackingHighlightedIndex = 0 + setNeedsDisplay(bounds) + if triggerAction { + if let target = target as? NSObject, let action = action { + target.perform(action, with: self) + } + } + } +} + +public class ctlCandidateUniversal: ctlCandidate { + private var candidateView: vwrCandidateUniversal + private var prevPageButton: NSButton + private var nextPageButton: NSButton + private var currentPageIndex: Int = 0 + override public var currentLayout: Layout { + get { candidateView.isVerticalLayout ? .vertical : .horizontal } + set { + switch newValue { + case .vertical: candidateView.isVerticalLayout = true + case .horizontal: candidateView.isVerticalLayout = false + } + } + } + + public init(_ layout: Layout = .horizontal) { + var contentRect = NSRect(x: 128.0, y: 128.0, width: 0.0, height: 0.0) + let styleMask: NSWindow.StyleMask = [.nonactivatingPanel] + let panel = NSPanel( + contentRect: contentRect, styleMask: styleMask, backing: .buffered, defer: false + ) + panel.level = NSWindow.Level(Int(kCGPopUpMenuWindowLevel) + 1) + panel.hasShadow = true + panel.isOpaque = false + panel.backgroundColor = NSColor.clear + + contentRect.origin = NSPoint.zero + candidateView = vwrCandidateUniversal(frame: contentRect) + + candidateView.wantsLayer = true + candidateView.layer?.borderColor = + NSColor.selectedMenuItemTextColor.withAlphaComponent(0.10).cgColor + candidateView.layer?.borderWidth = 1.0 + if #available(macOS 10.13, *) { + candidateView.layer?.cornerRadius = 6.0 + } + + panel.contentView?.addSubview(candidateView) + + contentRect.size = NSSize(width: 20.0, height: 10.0) // Reduce the button width + let buttonAttribute: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 9.0)] + + nextPageButton = NSButton(frame: contentRect) + NSColor.controlBackgroundColor.setFill() + NSBezierPath.fill(nextPageButton.bounds) + nextPageButton.wantsLayer = true + nextPageButton.layer?.masksToBounds = true + nextPageButton.layer?.borderColor = NSColor.clear.cgColor + nextPageButton.layer?.borderWidth = 0.0 + nextPageButton.setButtonType(.momentaryLight) + nextPageButton.bezelStyle = .disclosure + nextPageButton.userInterfaceLayoutDirection = .leftToRight + nextPageButton.attributedTitle = NSMutableAttributedString( + string: " ", attributes: buttonAttribute + ) // Next Page Arrow + prevPageButton = NSButton(frame: contentRect) + NSColor.controlBackgroundColor.setFill() + NSBezierPath.fill(prevPageButton.bounds) + prevPageButton.wantsLayer = true + prevPageButton.layer?.masksToBounds = true + prevPageButton.layer?.borderColor = NSColor.clear.cgColor + prevPageButton.layer?.borderWidth = 0.0 + prevPageButton.setButtonType(.momentaryLight) + prevPageButton.bezelStyle = .disclosure + prevPageButton.userInterfaceLayoutDirection = .rightToLeft + prevPageButton.attributedTitle = NSMutableAttributedString( + string: " ", attributes: buttonAttribute + ) // Previous Page Arrow + panel.contentView?.addSubview(nextPageButton) + panel.contentView?.addSubview(prevPageButton) + + super.init(window: panel) + currentLayout = layout + + candidateView.target = self + candidateView.action = #selector(candidateViewMouseDidClick(_:)) + + nextPageButton.target = self + nextPageButton.action = #selector(pageButtonAction(_:)) + + prevPageButton.target = self + prevPageButton.action = #selector(pageButtonAction(_:)) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func reloadData() { + candidateView.highlightedIndex = 0 + currentPageIndex = 0 + layoutCandidateView() + } + + override public func showNextPage() -> Bool { + guard delegate != nil else { return false } + if pageCount == 1 { return highlightNextCandidate() } + if currentPageIndex + 1 >= pageCount { clsSFX.beep() } + currentPageIndex = (currentPageIndex + 1 >= pageCount) ? 0 : currentPageIndex + 1 + candidateView.highlightedIndex = 0 + layoutCandidateView() + return true + } + + override public func showPreviousPage() -> Bool { + guard delegate != nil else { return false } + if pageCount == 1 { return highlightPreviousCandidate() } + if currentPageIndex == 0 { clsSFX.beep() } + currentPageIndex = (currentPageIndex == 0) ? pageCount - 1 : currentPageIndex - 1 + candidateView.highlightedIndex = 0 + layoutCandidateView() + return true + } + + override public func highlightNextCandidate() -> Bool { + guard let delegate = delegate else { return false } + selectedCandidateIndex = + (selectedCandidateIndex + 1 >= delegate.candidateCountForController(self)) + ? 0 : selectedCandidateIndex + 1 + return true + } + + override public func highlightPreviousCandidate() -> Bool { + guard let delegate = delegate else { return false } + selectedCandidateIndex = + (selectedCandidateIndex == 0) + ? delegate.candidateCountForController(self) - 1 : selectedCandidateIndex - 1 + return true + } + + override public func candidateIndexAtKeyLabelIndex(_ index: Int) -> Int { + guard let delegate = delegate else { + return Int.max + } + + let result = currentPageIndex * keyLabels.count + index + return result < delegate.candidateCountForController(self) ? result : Int.max + } + + override public var selectedCandidateIndex: Int { + get { + currentPageIndex * keyLabels.count + candidateView.highlightedIndex + } + set { + guard let delegate = delegate else { + return + } + let keyLabelCount = keyLabels.count + if newValue < delegate.candidateCountForController(self) { + currentPageIndex = newValue / keyLabelCount + candidateView.highlightedIndex = newValue % keyLabelCount + layoutCandidateView() + } + } + } +} + +extension ctlCandidateUniversal { + private var pageCount: Int { + guard let delegate = delegate else { + return 0 + } + let totalCount = delegate.candidateCountForController(self) + let keyLabelCount = keyLabels.count + return totalCount / keyLabelCount + ((totalCount % keyLabelCount) != 0 ? 1 : 0) + } + + private func layoutCandidateView() { + guard let delegate = delegate else { + return + } + + candidateView.set(keyLabelFont: keyLabelFont, candidateFont: candidateFont) + var candidates = [String]() + let count = delegate.candidateCountForController(self) + let keyLabelCount = keyLabels.count + + let begin = currentPageIndex * keyLabelCount + for index in begin.. 1, mgrPrefs.showPageButtonsInCandidateWindow { + var buttonRect = nextPageButton.frame + let spacing: CGFloat = 0.0 + + if currentLayout == .horizontal { buttonRect.size.height = floor(newSize.height / 2) } + var buttonOriginY = newSize.height - (buttonRect.size.height * 2.0 + spacing) + if currentLayout == .horizontal { buttonOriginY /= 2.0 } + + buttonRect.origin = NSPoint(x: newSize.width, y: buttonOriginY) + nextPageButton.frame = buttonRect + + buttonRect.origin = NSPoint( + x: newSize.width, y: buttonOriginY + buttonRect.size.height + spacing + ) + prevPageButton.frame = buttonRect + + newSize.width += 20 + nextPageButton.isHidden = false + prevPageButton.isHidden = false + } else { + nextPageButton.isHidden = true + prevPageButton.isHidden = true + } + + frameRect = window?.frame ?? NSRect.zero + + let topLeftPoint = NSPoint(x: frameRect.origin.x, y: frameRect.origin.y + frameRect.size.height) + frameRect.size = newSize + frameRect.origin = NSPoint(x: topLeftPoint.x, y: topLeftPoint.y - frameRect.size.height) + window?.setFrame(frameRect, display: false) + candidateView.setNeedsDisplay(candidateView.bounds) + } + + @objc private func pageButtonAction(_ sender: Any) { + guard let sender = sender as? NSButton else { + return + } + if sender == nextPageButton { + _ = showNextPage() + } else if sender == prevPageButton { + _ = showPreviousPage() + } + } + + @objc private func candidateViewMouseDidClick(_: Any) { + delegate?.ctlCandidate(self, didSelectCandidateAtIndex: selectedCandidateIndex) + } +} diff --git a/vChewing.xcodeproj/project.pbxproj b/vChewing.xcodeproj/project.pbxproj index 92f54571..af371279 100644 --- a/vChewing.xcodeproj/project.pbxproj +++ b/vChewing.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 5B0AF8B527B2C8290096FE54 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0AF8B427B2C8290096FE54 /* StringExtension.swift */; }; 5B11328927B94CFB00E58451 /* AppleKeyboardConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B11328827B94CFB00E58451 /* AppleKeyboardConverter.swift */; }; + 5B242403284B0D6500520FE4 /* ctlCandidateUniversal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B242402284B0D6500520FE4 /* ctlCandidateUniversal.swift */; }; 5B3133BF280B229700A4A505 /* KeyHandler_States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3133BE280B229700A4A505 /* KeyHandler_States.swift */; }; 5B38F59A281E2E49007D5F5D /* 6_Unigram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0D4F1D15FC0EB100ABF4B3 /* 6_Unigram.swift */; }; 5B38F59B281E2E49007D5F5D /* 7_KeyValuePair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0D4F1815FC0EB100ABF4B3 /* 7_KeyValuePair.swift */; }; @@ -191,6 +192,7 @@ 5B18BA7227C7BD8B0056EB19 /* LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE.txt; sourceTree = ""; }; 5B18BA7327C7BD8C0056EB19 /* LICENSE-JPN.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LICENSE-JPN.txt"; sourceTree = ""; }; 5B18BA7427C7BD8C0056EB19 /* LICENSE-CHT.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LICENSE-CHT.txt"; sourceTree = ""; }; + 5B242402284B0D6500520FE4 /* ctlCandidateUniversal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ctlCandidateUniversal.swift; sourceTree = ""; }; 5B2DB17127AF8771006D874E /* Makefile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.make; name = Makefile; path = Data/Makefile; sourceTree = ""; }; 5B30F11227BA568800484E24 /* vChewingKeyLayout.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = vChewingKeyLayout.bundle; sourceTree = ""; }; 5B3133BE280B229700A4A505 /* KeyHandler_States.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; fileEncoding = 4; indentWidth = 2; lineEnding = 0; path = KeyHandler_States.swift; sourceTree = ""; tabWidth = 2; usesTabs = 0; }; @@ -527,6 +529,7 @@ children = ( 5B62A34027AE7CD900A19448 /* ctlCandidate.swift */, 5B62A33F27AE7CD900A19448 /* ctlCandidateHorizontal.swift */, + 5B242402284B0D6500520FE4 /* ctlCandidateUniversal.swift */, 5B62A34127AE7CD900A19448 /* ctlCandidateVertical.swift */, ); path = CandidateUI; @@ -1078,6 +1081,7 @@ 5BA9FD0F27FEDB6B002DE248 /* suiPrefPaneGeneral.swift in Sources */, 5BA9FD4927FEF3C9002DE248 /* Section.swift in Sources */, 5BA9FD3E27FEF3C8002DE248 /* Utilities.swift in Sources */, + 5B242403284B0D6500520FE4 /* ctlCandidateUniversal.swift in Sources */, 5BA9FD1127FEDB6B002DE248 /* ctlPrefUI.swift in Sources */, 5B38F59C281E2E49007D5F5D /* 2_Grid.swift in Sources */, 5B40730D281672610023DFFF /* lmReplacements.swift in Sources */,