diff --git a/Source/Modules/WindowControllers/CtlRevLookupWindow.swift b/Source/Modules/WindowControllers/CtlRevLookupWindow.swift new file mode 100644 index 00000000..02a2c2d9 --- /dev/null +++ b/Source/Modules/WindowControllers/CtlRevLookupWindow.swift @@ -0,0 +1,193 @@ +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// 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 defined in MIT License. + +import Cocoa +import LangModelAssembly + +class CtlRevLookupWindow: NSWindowController, NSWindowDelegate { + static var shared: CtlRevLookupWindow? + + static func show() { + if shared == nil { Self.shared = .init(window: FrmRevLookupWindow()) } + guard let shared = Self.shared, let window = shared.window as? FrmRevLookupWindow else { return } + shared.window = window + window.delegate = shared + window.setPosition(vertical: .bottom, horizontal: .right, padding: 20) + window.orderFrontRegardless() // 逼著視窗往最前方顯示 + window.level = .statusBar + window.titlebarAppearsTransparent = true + shared.showWindow(shared) + NSApp.activate(ignoringOtherApps: true) + } +} + +class FrmRevLookupWindow: NSWindow { + typealias LMRevLookup = vChewingLM.LMRevLookup + + static let lmRevLookupCore = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup")) + + // 全字庫資料接近十萬筆索引,只放到單個 Dictionary 內的話、每次查詢時都會把輸入法搞崩潰。只能分卷處理。 + static let lmRevLookupCNS1 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS1")) + static let lmRevLookupCNS2 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS2")) + static let lmRevLookupCNS3 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS3")) + static let lmRevLookupCNS4 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS4")) + static let lmRevLookupCNS5 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS5")) + static let lmRevLookupCNS6 = LMRevLookup(path: LMMgr.getBundleDataPath("data-bpmf-reverse-lookup-CNS6")) + + public lazy var inputField = NSTextField() + public lazy var resultView = NSTextView() + private lazy var clipView = NSClipView() + private lazy var scrollView = NSScrollView() + private lazy var button = NSButton() + private lazy var view = NSView() + + init() { + super.init( + contentRect: CGRect(x: 196, y: 240, width: 480, height: 340), + styleMask: [.titled, .closable], + backing: .buffered, defer: true + ) + setupUI() + setupLayout() + } + + private func setupUI() { + contentView = view + + allowsToolTipsWhenApplicationIsInactive = false + autorecalculatesKeyViewLoop = false + isReleasedWhenClosed = false + title = "Reverse Lookup (Phonabets)".localized + + view.addSubview(inputField) + view.addSubview(scrollView) + view.addSubview(button) + + view.wantsLayer = true + + button.setContentHuggingPriority(.defaultHigh, for: .vertical) + button.translatesAutoresizingMaskIntoConstraints = false + button.alignment = .center + button.bezelStyle = .recessed + button.font = NSFont.systemFont(ofSize: 12, weight: .bold) + button.imageScaling = .scaleProportionallyDown + button.title = "👓" + button.cell.map { $0 as? NSButtonCell }??.isBordered = true + button.target = self + button.action = #selector(keyboardConfirmed(_:)) + button.keyEquivalent = String(utf16CodeUnits: [unichar(NSEvent.SpecialKey.enter.rawValue)], count: 1) as String + + scrollView.borderType = .noBorder + scrollView.hasHorizontalScroller = false + scrollView.horizontalLineScroll = 10 + scrollView.horizontalPageScroll = 10 + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.verticalLineScroll = 10 + scrollView.verticalPageScroll = 10 + + clipView.documentView = resultView + + clipView.autoresizingMask = [.width, .height] + clipView.drawsBackground = false + clipView.frame = CGRect(x: 0, y: 0, width: 480, height: 320) + + resultView.autoresizingMask = [.width, .height] + resultView.backgroundColor = NSColor.textBackgroundColor + resultView.frame = CGRect(x: 0, y: 0, width: 480, height: 320) + resultView.importsGraphics = false + resultView.insertionPointColor = NSColor.textColor + resultView.isEditable = false + resultView.isRichText = false + resultView.isVerticallyResizable = true + resultView.maxSize = CGSize(width: 774, height: 10_000_000) + resultView.minSize = CGSize(width: 480, height: 320) + resultView.smartInsertDeleteEnabled = true + resultView.textColor = NSColor.textColor + resultView.wantsLayer = true + resultView.font = NSFont.systemFont(ofSize: 13) + resultView.string = "Maximum 15 results returnable.".localized + + scrollView.contentView = clipView + + inputField.setContentHuggingPriority(.defaultHigh, for: .vertical) + inputField.translatesAutoresizingMaskIntoConstraints = false + inputField.backgroundColor = NSColor.textBackgroundColor + inputField.drawsBackground = true + inputField.font = NSFont.systemFont(ofSize: 13) + inputField.isBezeled = true + inputField.isEditable = true + inputField.isSelectable = true + inputField.lineBreakMode = .byClipping + inputField.textColor = NSColor.controlTextColor + inputField.cell.map { $0 as? NSTextFieldCell }??.isScrollable = true + inputField.cell.map { $0 as? NSTextFieldCell }??.sendsActionOnEndEditing = true + inputField.cell.map { $0 as? NSTextFieldCell }??.usesSingleLineMode = true + inputField.action = #selector(keyboardConfirmed(_:)) + inputField.toolTip = + "Maximum 15 results returnable.".localized + } + + @objc func keyboardConfirmed(_: Any?) { + if inputField.stringValue.isEmpty { return } + resultView.string = "\n" + "Loading…".localized + DispatchQueue.main.async { [self] in + self.updateResult(with: self.inputField.stringValue) + } + } + + private func updateResult(with input: String) { + guard !input.isEmpty else { return } + button.isEnabled = false + inputField.isEnabled = false + let strBuilder = NSMutableString() + strBuilder.append("\n") + strBuilder.append("Char\tReading(s)\n".localized) + strBuilder.append("==\t====\n") + var i = 0 + theLoop: for char in input.charComponents { + if i == 15 { + strBuilder.append("Maximum 15 results returnable.".localized + "\n") + break theLoop + } + var arrResult = Self.lmRevLookupCore.query(with: char) ?? [] + // 一般情況下,威注音語彙庫的倉庫內的全字庫資料檔案有做過排序,所以每個分卷的索引都是不重複的。 + arrResult += + Self.lmRevLookupCNS1.query(with: char) + ?? Self.lmRevLookupCNS2.query(with: char) + ?? Self.lmRevLookupCNS3.query(with: char) + ?? Self.lmRevLookupCNS4.query(with: char) + ?? Self.lmRevLookupCNS5.query(with: char) + ?? Self.lmRevLookupCNS6.query(with: char) + ?? [] + arrResult = arrResult.deduplicated + if !arrResult.isEmpty { + strBuilder.append(char + "\t") + strBuilder.append(arrResult.joined(separator: ", ")) + strBuilder.append("\n") + i += 1 + } + } + resultView.string = strBuilder.description + button.isEnabled = true + inputField.isEnabled = true + } + + private func setupLayout() { + inputField.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true + button.leadingAnchor.constraint(equalTo: inputField.trailingAnchor, constant: 5).isActive = true + scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true + inputField.lastBaselineAnchor.constraint(equalTo: button.lastBaselineAnchor).isActive = true + scrollView.topAnchor.constraint(equalTo: button.bottomAnchor).isActive = true + view.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: 3).isActive = true + inputField.topAnchor.constraint(equalTo: button.topAnchor).isActive = true + view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true + view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true + inputField.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + inputField.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + } +} diff --git a/Source/Resources/Base.lproj/Localizable.strings b/Source/Resources/Base.lproj/Localizable.strings index 71dd1418..98182d8f 100644 --- a/Source/Resources/Base.lproj/Localizable.strings +++ b/Source/Resources/Base.lproj/Localizable.strings @@ -16,6 +16,10 @@ "Loading…" = "Loading…"; "Consolidate" = "Consolidate"; "Reload" = "Reload"; +"Loading complete." = "Loading complete."; +"Char\tReading(s)\n" = "Char\tReading(s)\n"; +"Reverse Lookup (Phonabets)" = "Reverse Lookup (Phonabets)"; +"Maximum 15 results returnable." = "Maximum 15 results returnable."; "Example:\nCandidate Reading-Reading #Comment" = "Example:\nCandidate Reading-Reading #Comment"; "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment" = "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment"; "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..." = "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..."; diff --git a/Source/Resources/en.lproj/Localizable.strings b/Source/Resources/en.lproj/Localizable.strings index 71dd1418..98182d8f 100644 --- a/Source/Resources/en.lproj/Localizable.strings +++ b/Source/Resources/en.lproj/Localizable.strings @@ -16,6 +16,10 @@ "Loading…" = "Loading…"; "Consolidate" = "Consolidate"; "Reload" = "Reload"; +"Loading complete." = "Loading complete."; +"Char\tReading(s)\n" = "Char\tReading(s)\n"; +"Reverse Lookup (Phonabets)" = "Reverse Lookup (Phonabets)"; +"Maximum 15 results returnable." = "Maximum 15 results returnable."; "Example:\nCandidate Reading-Reading #Comment" = "Example:\nCandidate Reading-Reading #Comment"; "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment" = "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment"; "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..." = "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..."; diff --git a/Source/Resources/ja.lproj/Localizable.strings b/Source/Resources/ja.lproj/Localizable.strings index e1a7425b..c9fa0fd6 100644 --- a/Source/Resources/ja.lproj/Localizable.strings +++ b/Source/Resources/ja.lproj/Localizable.strings @@ -16,6 +16,10 @@ "Loading…" = "読み込む中…"; "Consolidate" = "整理"; "Reload" = "再読込"; +"Loading complete." = "読込完了。"; +"Char\tReading(s)\n" = "漢字\t音読\n"; +"Reverse Lookup (Phonabets)" = "注音音読逆引参照"; +"Maximum 15 results returnable." = "参照結果は最初の15件のみ表示可能。"; "Example:\nCandidate Reading-Reading #Comment" = "【模範例】\n候補 音読-音読 #メモ"; "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment" = "【模範例】\n候補 音読-音読 優先度 #メモ\n候補 音読-音読 #メモ"; "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..." = "【模範例】\n頭文字 残候補\n頭文字 残候補1 残候補2 残候補3..."; diff --git a/Source/Resources/zh-Hans.lproj/Localizable.strings b/Source/Resources/zh-Hans.lproj/Localizable.strings index c4044d50..da5c18db 100644 --- a/Source/Resources/zh-Hans.lproj/Localizable.strings +++ b/Source/Resources/zh-Hans.lproj/Localizable.strings @@ -16,6 +16,10 @@ "Loading…" = "正在载入…"; "Consolidate" = "整理"; "Reload" = "重新载入"; +"Loading complete." = "载入完毕。"; +"Char\tReading(s)\n" = "汉字\t读音\n"; +"Reverse Lookup (Phonabets)" = "注音反查"; +"Maximum 15 results returnable." = "仅能给出前 15 笔结果。"; "Example:\nCandidate Reading-Reading #Comment" = "【范例】\n候选字词 读音-读音 #注解"; "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment" = "【范例】\n候选字词 读音-读音 权重 #注解\n候选字词 读音-读音 #注解"; "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..." = "【范例】\n首字 候选\n首字 候选1 候选2 候选3..."; diff --git a/Source/Resources/zh-Hant.lproj/Localizable.strings b/Source/Resources/zh-Hant.lproj/Localizable.strings index 94b077d6..23e25db2 100644 --- a/Source/Resources/zh-Hant.lproj/Localizable.strings +++ b/Source/Resources/zh-Hant.lproj/Localizable.strings @@ -16,6 +16,10 @@ "Loading…" = "正在載入…"; "Consolidate" = "整理"; "Reload" = "重新載入"; +"Loading complete." = "載入完畢。"; +"Char\tReading(s)\n" = "漢字\t讀音\n"; +"Reverse Lookup (Phonabets)" = "注音反查"; +"Maximum 15 results returnable." = "僅能給出前 15 筆結果。"; "Example:\nCandidate Reading-Reading #Comment" = "【範例】\n候選字詞 讀音-讀音 #註解"; "Example:\nCandidate Reading-Reading Weight #Comment\nCandidate Reading-Reading #Comment" = "【範例】\n候選字詞 讀音-讀音 權重 #註解\n候選字詞 讀音-讀音 #註解"; "Example:\nInitial RestPhrase\nInitial RestPhrase1 RestPhrase2 RestPhrase3..." = "【範例】\n首字 候選\n首字 候選1 候選2 候選3..."; diff --git a/vChewing.xcodeproj/project.pbxproj b/vChewing.xcodeproj/project.pbxproj index ddaaef0b..ed5322bb 100644 --- a/vChewing.xcodeproj/project.pbxproj +++ b/vChewing.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 5B253E822945AF6700680C67 /* data-bpmf-reverse-lookup-CNS4.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5B253E7C2945AF6700680C67 /* data-bpmf-reverse-lookup-CNS4.plist */; }; 5B253E832945AF6700680C67 /* data-bpmf-reverse-lookup-CNS5.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5B253E7D2945AF6700680C67 /* data-bpmf-reverse-lookup-CNS5.plist */; }; 5B2E009428FD1E8100E78D6E /* VwrPrefPaneCassette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2E009328FD1E8100E78D6E /* VwrPrefPaneCassette.swift */; }; + 5B30BF282944867800BD87A9 /* CtlRevLookupWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B30BF272944867800BD87A9 /* CtlRevLookupWindow.swift */; }; 5B3133BF280B229700A4A505 /* InputHandler_HandleStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3133BE280B229700A4A505 /* InputHandler_HandleStates.swift */; }; 5B40113928D7050D00A9D4CB /* Shared in Frameworks */ = {isa = PBXBuildFile; productRef = 5B40113828D7050D00A9D4CB /* Shared */; }; 5B40113C28D71C0100A9D4CB /* Uninstaller in Frameworks */ = {isa = PBXBuildFile; productRef = 5B40113B28D71C0100A9D4CB /* Uninstaller */; }; @@ -215,6 +216,7 @@ 5B2DB17127AF8771006D874E /* Makefile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.make; name = Makefile; path = Data/Makefile; sourceTree = ""; }; 5B2E009328FD1E8100E78D6E /* VwrPrefPaneCassette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VwrPrefPaneCassette.swift; sourceTree = ""; }; 5B2F2BB3286216A500B8557B /* vChewingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = vChewingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5B30BF272944867800BD87A9 /* CtlRevLookupWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CtlRevLookupWindow.swift; sourceTree = ""; }; 5B30F11227BA568800484E24 /* vChewingKeyLayout.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = vChewingKeyLayout.bundle; sourceTree = ""; }; 5B312684287800DE001AA720 /* FAQ.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = FAQ.md; sourceTree = ""; }; 5B3133BE280B229700A4A505 /* InputHandler_HandleStates.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; fileEncoding = 4; indentWidth = 2; lineEnding = 0; path = InputHandler_HandleStates.swift; sourceTree = ""; tabWidth = 2; usesTabs = 0; }; @@ -470,6 +472,7 @@ 5B0EF55E28CDBF8E00F8F7CE /* CtlClientListMgr.swift */, D47F7DCD278BFB57002F9DD7 /* CtlPrefWindow.swift */, 5BCC631529407BBB00A2D84F /* CtlPrefWindow_PhraseEditor.swift */, + 5B30BF272944867800BD87A9 /* CtlRevLookupWindow.swift */, ); path = WindowControllers; sourceTree = ""; @@ -1090,6 +1093,7 @@ 5B78EE0D28A562B4009456C1 /* VwrPrefPaneDevZone.swift in Sources */, 5B6C141228A9D4B30098ADF8 /* SessionCtl_HandleEvent.swift in Sources */, D47F7DCE278BFB57002F9DD7 /* CtlPrefWindow.swift in Sources */, + 5B30BF282944867800BD87A9 /* CtlRevLookupWindow.swift in Sources */, 5BD0113D2818543900609769 /* InputHandler_Core.swift in Sources */, 5BF56F9A28C39D1800DD6839 /* IMEStateData.swift in Sources */, 5B21176C287539BB000443A9 /* SessionCtl_HandleStates.swift in Sources */,