From 6744d1830772c5b5fa57047773f0aa3b2ff0b35f Mon Sep 17 00:00:00 2001 From: Judas Desloria Date: Wed, 14 Oct 2026 23:05:53 +0700 Subject: [PATCH] =?UTF-8?q?B=E1=BB=99=20nh=E1=BA=ADn=20di=E1=BB=87n=20c?= =?UTF-8?q?=C3=A1c=20ch=E1=BB=AF=20c=C3=A1i=20=C4=91=E1=BA=B1ng=20tr=C6=B0?= =?UTF-8?q?=E1=BB=9Bc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (hỗ trợ web - DOM HTML, native macos - appkit/swift) (không hỗ trợ richedit, canvas web, electron, custom gpu/cef, terminal/cli) --- .clangd | 10 + .gitignore | 5 +- Sources/OpenKey/engine/Engine.cpp | 39 +- Sources/OpenKey/engine/Engine.h | 8 + .../macOS/ModernKey/MacroViewController.mm | 6 + Sources/OpenKey/macOS/ModernKey/OpenKey.mm | 529 +++++++++++++++++- .../macOS/OpenKey.xcodeproj/project.pbxproj | 8 +- build.sh | 27 + 8 files changed, 608 insertions(+), 24 deletions(-) create mode 100644 .clangd create mode 100755 build.sh diff --git a/.clangd b/.clangd new file mode 100644 index 00000000..48099ae3 --- /dev/null +++ b/.clangd @@ -0,0 +1,10 @@ +CompileFlags: + Add: + - "-isysroot" + - "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" + - "-I${workspaceFolder}/Sources/OpenKey/engine" + - "-I${workspaceFolder}/Sources/OpenKey/macOS/ModernKey" + - "-x" + - "objective-c++" + - "-std=c++11" + - "-fobjc-arc" diff --git a/.gitignore b/.gitignore index 7fd14bb2..6cfaade5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -################################################################################ +################################################################################ # This .gitignore file was automatically created by Microsoft(R) Visual Studio. ################################################################################ @@ -8,3 +8,6 @@ /Sources/OpenKey/win32/OpenKey/OpenKey/x64/Debug /Sources/OpenKey/win32/OpenKey/OpenKey/x64/Release /Sources/OpenKey/win32/OpenKey/OpenKeyUpdate/x64/Release +build/ +.DS_Store +scratch/ diff --git a/Sources/OpenKey/engine/Engine.cpp b/Sources/OpenKey/engine/Engine.cpp index de90833b..5e8e0918 100644 --- a/Sources/OpenKey/engine/Engine.cpp +++ b/Sources/OpenKey/engine/Engine.cpp @@ -465,6 +465,36 @@ void startNewSession() { _longWordHelper.clear(); } +Byte getCurrentWordIndex() { + return _index; +} + +bool isCurrentWordActive() { + return _index > 0 && _spaceCount == 0; +} + +bool loadCurrentTypingWord(const std::vector& wordData) { + if (wordData.empty() || wordData.size() >= MAX_BUFF) return false; + startNewSession(); + _index = (Byte)wordData.size(); + for (size_t idx = 0; idx < wordData.size(); idx++) { + TypingWord[idx] = wordData[idx]; + KeyStates[idx] = (wordData[idx] & CHAR_MASK) | (wordData[idx] & CAPS_MASK); + } + _stateIndex = _index; + _spellingEndIndex = _index; + findAndCalculateVowel(); + return true; +} + +void getCurrentTypingWord(std::vector& outData) { + outData.clear(); + outData.reserve(_index); + for (Byte i = 0; i < _index; i++) { + outData.push_back(TypingWord[i]); + } +} + void checkCorrectVowel(vector>& charset, int& i, int& k, const Uint16& markKey) { //ignore "qu" case if (_index >= 2 && CHR(_index-1) == KEY_U && CHR(_index-2) == KEY_Q) { @@ -669,9 +699,9 @@ void handleModernMark() { CHR(VSI+2) == KEY_M || CHR(VSI+2) == KEY_N || CHR(VSI+2) == KEY_O || CHR(VSI+2) == KEY_U || CHR(VSI+2) == KEY_I || CHR(VSI+2) == KEY_C || - (VSI+3 < _index && CHR(VSI+2) == KEY_C && CHR(VSI+2) == KEY_H) || - (VSI+3 < _index && CHR(VSI+2) == KEY_N && CHR(VSI+2) == KEY_H) || - (VSI+3 < _index && CHR(VSI+2) == KEY_N && CHR(VSI+2) == KEY_G)) { + (VSI+3 < _index && CHR(VSI+2) == KEY_C && CHR(VSI+3) == KEY_H) || + (VSI+3 < _index && CHR(VSI+2) == KEY_N && CHR(VSI+3) == KEY_H) || + (VSI+3 < _index && CHR(VSI+2) == KEY_N && CHR(VSI+3) == KEY_G)) { VWSM = VSI + 1; hBPC = _index - VWSM; @@ -799,9 +829,8 @@ void insertMark(const Uint32& markMask, const bool& canModifyFlag) { } hData[kk--] = GET(TypingWord[ii]); } - - hBPC = _index - VSI; } + hBPC = _index - VSI; hNCC = hBPC; } diff --git a/Sources/OpenKey/engine/Engine.h b/Sources/OpenKey/engine/Engine.h index f288f8c5..7fdef77f 100644 --- a/Sources/OpenKey/engine/Engine.h +++ b/Sources/OpenKey/engine/Engine.h @@ -216,6 +216,14 @@ void vKeyHandleEvent(const vKeyEvent& event, */ void startNewSession(); +/** + * Get current word index and load external word into engine + */ +Byte getCurrentWordIndex(); +bool isCurrentWordActive(); +bool loadCurrentTypingWord(const std::vector& wordData); +void getCurrentTypingWord(std::vector& outData); + /** * do some task in english mode (use for macro) */ diff --git a/Sources/OpenKey/macOS/ModernKey/MacroViewController.mm b/Sources/OpenKey/macOS/ModernKey/MacroViewController.mm index 54e5998b..21f6c91d 100644 --- a/Sources/OpenKey/macOS/ModernKey/MacroViewController.mm +++ b/Sources/OpenKey/macOS/ModernKey/MacroViewController.mm @@ -7,7 +7,11 @@ // #import "MacroViewController.h" +#if __has_include("Engine.h") #include "Engine.h" +#elif __has_include("../../engine/Engine.h") +#include "../../engine/Engine.h" +#endif #define MACRO_ADD_TEXT @"Thêm" #define MACRO_EDIT_TEXT @"Sửa" @@ -22,6 +26,8 @@ @implementation MacroViewController{ vector macroContent; } +@synthesize macroContent = _macroContent; + - (void)viewDidLoad { [super viewDidLoad]; self.tableView.dataSource = self; diff --git a/Sources/OpenKey/macOS/ModernKey/OpenKey.mm b/Sources/OpenKey/macOS/ModernKey/OpenKey.mm index 1e6b6c14..9062789a 100644 --- a/Sources/OpenKey/macOS/ModernKey/OpenKey.mm +++ b/Sources/OpenKey/macOS/ModernKey/OpenKey.mm @@ -8,7 +8,16 @@ #import #import #import +#import +#import +#import +#import +#import +#if __has_include("Engine.h") #import "Engine.h" +#elif __has_include("../../engine/Engine.h") +#import "../../engine/Engine.h" +#endif #import "AppDelegate.h" #import "ViewController.h" @@ -85,6 +94,7 @@ vector savedSmartSwitchKeyData; ////use for smart switch key NSString* _frontMostApp = @"UnknownApp"; + void initReverseCharMap(void); void OpenKeyInit() { //load saved data @@ -142,6 +152,8 @@ void OpenKeyInit() { if (convertToolHotKey == 0) { convertToolHotKey = EMPTY_HOTKEY; } + + initReverseCharMap(); } void RequestNewSession() { @@ -186,17 +198,452 @@ BOOL isSpotlightVisible() { return false; } + BOOL isTerminalApp(NSString* topApp) { + if (!topApp || topApp.length == 0) return NO; + static NSSet *terminalApps = nil; + if (!terminalApps) { + terminalApps = [[NSSet alloc] initWithArray:@[ + @"com.apple.Terminal", + @"com.googlecode.iterm2", + @"net.kovidgoyal.kitty", + @"alacritty", + @"io.alacritty", + @"org.alacritty", + @"dev.warp.Warp-Stable", + @"dev.warp.Warp", + @"com.mitchellh.ghostty", + @"com.github.wez.wezterm", + @"co.zeit.hyper", + @"org.tabby" + ]]; + } + if ([terminalApps containsObject:topApp]) return YES; + NSString *lower = [topApp lowercaseString]; + if ([lower containsString:@"terminal"] || + [lower containsString:@"iterm"] || + [lower containsString:@"ghostty"] || + [lower containsString:@"alacritty"] || + [lower containsString:@"kitty"] || + [lower containsString:@"wezterm"] || + [lower containsString:@"warp"]) { + return YES; + } + return NO; + } + + BOOL isBrowserApp(NSString* topApp) { + if (!topApp || topApp.length == 0) return NO; + static NSSet *browserApps = nil; + if (!browserApps) { + browserApps = [[NSSet alloc] initWithArray:@[ + @"com.google.Chrome", + @"com.google.Chrome.canary", + @"com.apple.Safari", + @"com.apple.SafariTechnologyPreview", + @"com.brave.Browser", + @"com.microsoft.edgemac", + @"com.microsoft.edgemac.Dev", + @"com.microsoft.edgemac.Beta", + @"com.microsoft.Edge", + @"org.mozilla.firefox", + @"com.operasoftware.Opera", + @"com.vivaldi.Vivaldi", + @"company.thebrowser.Browser" + ]]; + } + if ([browserApps containsObject:topApp]) return YES; + NSString *lower = [topApp lowercaseString]; + return [lower containsString:@"chrome"] || + [lower containsString:@"browser"] || + [lower containsString:@"safari"] || + [lower containsString:@"firefox"] || + [lower containsString:@"edge"]; + } + BOOL shouldUseRecommendWorkaround(NSString* topApp) { if (!vFixRecommendBrowser) return false; if (isSpotlightVisible()) return false; - if (topApp == nil) return true; + if (topApp == nil) return false; + if (isTerminalApp(topApp)) return false; + if (!isBrowserApp(topApp)) return false; return ![_recommendWorkaroundDisabledApp containsObject:topApp]; } BOOL shouldUseSelectionReplacement(NSString* topApp) { + if (isTerminalApp(topApp)) return false; return isSpotlightVisible() || [_recommendWorkaroundDisabledApp containsObject:topApp]; } + std::unordered_map _reverseCharMap; + + void initReverseCharMap() { + _reverseCharMap.clear(); + Uint16 keyMap[26] = { + KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, + KEY_J, KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, + KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z + }; + for (int i = 0; i < 26; i++) { + _reverseCharMap['a' + i] = keyMap[i]; + _reverseCharMap['A' + i] = keyMap[i] | CAPS_MASK; + } + + _reverseCharMap[0x0110] = KEY_D | TONE_MASK | CAPS_MASK; // Đ + _reverseCharMap[0x0111] = KEY_D | TONE_MASK; // đ + + auto reg = [](unichar ch, Uint32 val) { + if (ch != 0) _reverseCharMap[ch] = val; + }; + + Uint32 markMasks[5] = {MARK1_MASK, MARK2_MASK, MARK3_MASK, MARK4_MASK, MARK5_MASK}; + + // A + unichar aBasic[14] = {0x00C2, 0x00E2, 0x0102, 0x0103, 0x00C1, 0x00E1, 0x00C0, 0x00E0, 0x1EA2, 0x1EA3, 0x00C3, 0x00E3, 0x1EA0, 0x1EA1}; + reg(aBasic[0], KEY_A | TONE_MASK | CAPS_MASK); reg(aBasic[1], KEY_A | TONE_MASK); + reg(aBasic[2], KEY_A | TONEW_MASK | CAPS_MASK); reg(aBasic[3], KEY_A | TONEW_MASK); + for (int m = 0; m < 5; m++) { + reg(aBasic[4 + m*2], KEY_A | markMasks[m] | CAPS_MASK); + reg(aBasic[5 + m*2], KEY_A | markMasks[m]); + } + + // A with TONE_MASK (Â: Ấ, ấ, Ầ, ầ, Ẩ, ẩ, Ẫ, ẫ, Ậ, ậ) + unichar aTone[10] = {0x1EA4, 0x1EA5, 0x1EA6, 0x1EA7, 0x1EA8, 0x1EA9, 0x1EAA, 0x1EAB, 0x1EAC, 0x1EAD}; + for (int m = 0; m < 5; m++) { + reg(aTone[m*2], KEY_A | TONE_MASK | markMasks[m] | CAPS_MASK); + reg(aTone[m*2 + 1], KEY_A | TONE_MASK | markMasks[m]); + } + + // A with TONEW_MASK (Ă: Ắ, ắ, Ằ, ằ, Ẳ, ẳ, Ẵ, ẵ, Ặ, ặ) + unichar aToneW[10] = {0x1EAE, 0x1EAF, 0x1EB0, 0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4, 0x1EB5, 0x1EB6, 0x1EB7}; + for (int m = 0; m < 5; m++) { + reg(aToneW[m*2], KEY_A | TONEW_MASK | markMasks[m] | CAPS_MASK); + reg(aToneW[m*2 + 1], KEY_A | TONEW_MASK | markMasks[m]); + } + + // E + unichar eBasic[14] = {0x00CA, 0x00EA, 0, 0, 0x00C9, 0x00E9, 0x00C8, 0x00E8, 0x1EBA, 0x1EBB, 0x1EBC, 0x1EBD, 0x1EB8, 0x1EB9}; + reg(eBasic[0], KEY_E | TONE_MASK | CAPS_MASK); reg(eBasic[1], KEY_E | TONE_MASK); + for (int m = 0; m < 5; m++) { + reg(eBasic[4 + m*2], KEY_E | markMasks[m] | CAPS_MASK); + reg(eBasic[5 + m*2], KEY_E | markMasks[m]); + } + // E with TONE_MASK (Ê: Ế, ế, Ề, ề, Ể, ể, Ễ, ễ, Ệ, ệ) + unichar eTone[10] = {0x1EBE, 0x1EBF, 0x1EC0, 0x1EC1, 0x1EC2, 0x1EC3, 0x1EC4, 0x1EC5, 0x1EC6, 0x1EC7}; + for (int m = 0; m < 5; m++) { + reg(eTone[m*2], KEY_E | TONE_MASK | markMasks[m] | CAPS_MASK); + reg(eTone[m*2 + 1], KEY_E | TONE_MASK | markMasks[m]); + } + + // O + unichar oBasic[14] = {0x00D4, 0x00F4, 0x01A0, 0x01A1, 0x00D3, 0x00F3, 0x00D2, 0x00F2, 0x1ECE, 0x1ECF, 0x00D5, 0x00F5, 0x1ECC, 0x1ECD}; + reg(oBasic[0], KEY_O | TONE_MASK | CAPS_MASK); reg(oBasic[1], KEY_O | TONE_MASK); + reg(oBasic[2], KEY_O | TONEW_MASK | CAPS_MASK); reg(oBasic[3], KEY_O | TONEW_MASK); + for (int m = 0; m < 5; m++) { + reg(oBasic[4 + m*2], KEY_O | markMasks[m] | CAPS_MASK); + reg(oBasic[5 + m*2], KEY_O | markMasks[m]); + } + // O with TONE_MASK (Ô: Ố, ố, Ồ, ồ, Ổ, ổ, Ỗ, ỗ, Ộ, ộ) + unichar oTone[10] = {0x1ED0, 0x1ED1, 0x1ED2, 0x1ED3, 0x1ED4, 0x1ED5, 0x1ED6, 0x1ED7, 0x1ED8, 0x1ED9}; + for (int m = 0; m < 5; m++) { + reg(oTone[m*2], KEY_O | TONE_MASK | markMasks[m] | CAPS_MASK); + reg(oTone[m*2 + 1], KEY_O | TONE_MASK | markMasks[m]); + } + // O with TONEW_MASK (Ơ: Ớ, ớ, Ờ, ờ, Ở, ở, Ỡ, ỡ, Ợ, ợ) + unichar oToneW[10] = {0x1EDA, 0x1EDB, 0x1EDC, 0x1EDD, 0x1EDE, 0x1EDF, 0x1EE0, 0x1EE1, 0x1EE2, 0x1EE3}; + for (int m = 0; m < 5; m++) { + reg(oToneW[m*2], KEY_O | TONEW_MASK | markMasks[m] | CAPS_MASK); + reg(oToneW[m*2 + 1], KEY_O | TONEW_MASK | markMasks[m]); + } + + // U + unichar uBasic[14] = {0, 0, 0x01AF, 0x01B0, 0x00DA, 0x00FA, 0x00D9, 0x00F9, 0x1EE6, 0x1EE7, 0x0168, 0x0169, 0x1EE4, 0x1EE5}; + reg(uBasic[2], KEY_U | TONEW_MASK | CAPS_MASK); reg(uBasic[3], KEY_U | TONEW_MASK); + for (int m = 0; m < 5; m++) { + reg(uBasic[4 + m*2], KEY_U | markMasks[m] | CAPS_MASK); + reg(uBasic[5 + m*2], KEY_U | markMasks[m]); + } + // U with TONEW_MASK (Ư: Ứ, ứ, Ừ, ừ, Sử, ử, Ữ, ữ, Ự, ự) + unichar uToneW[10] = {0x1EE8, 0x1EE9, 0x1EEA, 0x1EEB, 0x1EEC, 0x1EED, 0x1EEE, 0x1EEF, 0x1EF0, 0x1EF1}; + for (int m = 0; m < 5; m++) { + reg(uToneW[m*2], KEY_U | TONEW_MASK | markMasks[m] | CAPS_MASK); + reg(uToneW[m*2 + 1], KEY_U | TONEW_MASK | markMasks[m]); + } + + // I (Í, í, Ì, ì, Ỉ, ỉ, Ĩ, ĩ, Ị, ị) + unichar iBasic[10] = {0x00CD, 0x00ED, 0x00CC, 0x00EC, 0x1EC8, 0x1EC9, 0x0128, 0x0129, 0x1ECA, 0x1ECB}; + for (int m = 0; m < 5; m++) { + reg(iBasic[m*2], KEY_I | markMasks[m] | CAPS_MASK); + reg(iBasic[m*2 + 1], KEY_I | markMasks[m]); + } + + // Y (Ý, ý, Kỳ, ỳ, Ỷ, ỷ, Ỹ, ỹ, Ỵ, ỵ) + unichar yBasic[10] = {0x00DD, 0x00FD, 0x1EF2, 0x1EF3, 0x1EF6, 0x1EF7, 0x1EF8, 0x1EF9, 0x1EF4, 0x1EF5}; + for (int m = 0; m < 5; m++) { + reg(yBasic[m*2], KEY_Y | markMasks[m] | CAPS_MASK); + reg(yBasic[m*2 + 1], KEY_Y | markMasks[m]); + } + } + + static inline Uint32 lookupReverseChar(unichar ch) { + if (_reverseCharMap.empty()) initReverseCharMap(); + auto it = _reverseCharMap.find(ch); + if (it != _reverseCharMap.end()) return it->second; + return 0xFFFFFFFF; + } + + static inline BOOL isVietnameseWordChar(unichar ch) { + return lookupReverseChar(ch) != 0xFFFFFFFF; + } + + static inline BOOL isAlphanumericKeyCode(CGKeyCode code) { + switch (code) { + case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7: + case 8: case 9: case 11: case 12: case 13: case 14: case 15: case 16: + case 17: case 31: case 32: case 34: case 35: case 37: case 38: case 40: + case 45: case 46: + case 18: case 19: case 20: case 21: case 22: case 23: case 25: case 26: + case 28: case 29: + case 30: case 33: + return YES; + default: + return NO; + } + } + + enum class CaretRestoreResult { + RestoredWord, + NoWordAtCaret, + Unsupported + }; + + CaretRestoreResult tryRestoreWordFromCaretContext() { + if (vLanguage == 0 || getCurrentWordIndex() > 0) return CaretRestoreResult::Unsupported; + if (_reverseCharMap.empty()) initReverseCharMap(); + + NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; + if (!frontApp) return CaretRestoreResult::Unsupported; + if (isTerminalApp(frontApp.bundleIdentifier)) { + return CaretRestoreResult::Unsupported; + } + + AXUIElementRef focusedElem = NULL; + + // Strategy 1: System-Wide Focus (direct from WindowServer) + AXUIElementRef sysWide = AXUIElementCreateSystemWide(); + if (sysWide) { + AXError sysErr = AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute, (CFTypeRef *)&focusedElem); + CFRelease(sysWide); + if (sysErr == kAXErrorSuccess && focusedElem) { + pid_t elemPid = 0; + if (AXUIElementGetPid(focusedElem, &elemPid) != kAXErrorSuccess || elemPid != frontApp.processIdentifier) { + CFRelease(focusedElem); + focusedElem = NULL; + } + } + } + + // Strategy 2: App-level Focus fallback + if (!focusedElem) { + AXUIElementRef appElem = AXUIElementCreateApplication(frontApp.processIdentifier); + if (appElem) { + AXError appErr = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute, (CFTypeRef *)&focusedElem); + if (appErr != kAXErrorSuccess || !focusedElem) { + // Strategy 3: Focused Window's focused element fallback + AXUIElementRef focusedWin = NULL; + if (AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute, (CFTypeRef *)&focusedWin) == kAXErrorSuccess && focusedWin) { + AXUIElementCopyAttributeValue(focusedWin, kAXFocusedUIElementAttribute, (CFTypeRef *)&focusedElem); + CFRelease(focusedWin); + } + } + CFRelease(appElem); + } + } + + if (!focusedElem) return CaretRestoreResult::Unsupported; + + // Check role of focusedElem + CFTypeRef roleVal = NULL; + AXUIElementCopyAttributeValue(focusedElem, kAXRoleAttribute, &roleVal); + NSString *role = roleVal ? (__bridge NSString *)roleVal : @""; + if (roleVal) CFRelease(roleVal); + + // If role is AXWebArea, Chrome/Electron web content has not exposed inner elements + if ([role isEqualToString:@"AXWebArea"]) { + CFRelease(focusedElem); + return CaretRestoreResult::Unsupported; + } + + AXValueRef selectedRangeVal = NULL; + AXError err = AXUIElementCopyAttributeValue(focusedElem, kAXSelectedTextRangeAttribute, (CFTypeRef *)&selectedRangeVal); + if (err != kAXErrorSuccess || !selectedRangeVal) { + // Check if focusedElem has an inner focused child (e.g. if container was returned) + AXUIElementRef innerFocused = NULL; + if (AXUIElementCopyAttributeValue(focusedElem, kAXFocusedUIElementAttribute, (CFTypeRef *)&innerFocused) == kAXErrorSuccess && innerFocused) { + CFRelease(focusedElem); + focusedElem = innerFocused; + err = AXUIElementCopyAttributeValue(focusedElem, kAXSelectedTextRangeAttribute, (CFTypeRef *)&selectedRangeVal); + } + } + if (err != kAXErrorSuccess || !selectedRangeVal) { + CFRelease(focusedElem); + return CaretRestoreResult::Unsupported; + } + + CFRange selectedRange; + BOOL gotRange = AXValueGetValue(selectedRangeVal, kAXValueTypeCFRange, &selectedRange); + CFRelease(selectedRangeVal); + if (!gotRange || selectedRange.length > 0) { + CFRelease(focusedElem); + return CaretRestoreResult::Unsupported; + } + if (selectedRange.location <= 0) { + CFRelease(focusedElem); + return CaretRestoreResult::NoWordAtCaret; + } + + // Scan backwards up to 30 characters before caret + CFIndex maxScan = 30; + CFIndex start = (selectedRange.location > maxScan) ? (selectedRange.location - maxScan) : 0; + CFIndex length = selectedRange.location - start; + CFRange queryRange = CFRangeMake(start, length); + + NSString *textBefore = nil; + AXValueRef queryRangeVal = AXValueCreate(kAXValueTypeCFRange, &queryRange); + if (queryRangeVal) { + CFTypeRef stringVal = NULL; + err = AXUIElementCopyParameterizedAttributeValue(focusedElem, kAXStringForRangeParameterizedAttribute, queryRangeVal, &stringVal); + CFRelease(queryRangeVal); + if (err == kAXErrorSuccess && stringVal) { + if (CFGetTypeID(stringVal) == CFStringGetTypeID()) { + textBefore = [NSString stringWithString:(__bridge NSString *)stringVal]; + } else if (CFGetTypeID(stringVal) == CFAttributedStringGetTypeID()) { + textBefore = [NSString stringWithString:(__bridge NSString *)CFAttributedStringGetString((CFAttributedStringRef)stringVal)]; + } + CFRelease(stringVal); + } + } + + if (!textBefore || textBefore.length == 0) { + CFTypeRef fullVal = NULL; + err = AXUIElementCopyAttributeValue(focusedElem, kAXValueAttribute, &fullVal); + if (err == kAXErrorSuccess && fullVal) { + NSString *fullText = nil; + if (CFGetTypeID(fullVal) == CFStringGetTypeID()) { + fullText = (__bridge NSString *)fullVal; + } else if (CFGetTypeID(fullVal) == CFAttributedStringGetTypeID()) { + fullText = (__bridge NSString *)CFAttributedStringGetString((CFAttributedStringRef)fullVal); + } + if (fullText && fullText.length > 0) { + NSInteger subStart = (NSInteger)start; + NSInteger subLen = (NSInteger)length; + if (subStart < 0) subStart = 0; + if (subStart + subLen > (NSInteger)fullText.length) { + subLen = (NSInteger)fullText.length - subStart; + } + if (subStart < (NSInteger)fullText.length && subLen > 0) { + textBefore = [fullText substringWithRange:NSMakeRange((NSUInteger)subStart, (NSUInteger)subLen)]; + } + } + CFRelease(fullVal); + } + } + CFRelease(focusedElem); + + if (!textBefore || textBefore.length == 0) { + return CaretRestoreResult::Unsupported; + } + + // Normalize to NFC (precomposed) so accents are combined with base letters + textBefore = [textBefore precomposedStringWithCanonicalMapping]; + + // Extract the word immediately preceding the caret + NSInteger endIdx = (NSInteger)textBefore.length - 1; + NSInteger startIdx = endIdx; + + while (startIdx >= 0) { + unichar ch = [textBefore characterAtIndex:(NSUInteger)startIdx]; + if (isVietnameseWordChar(ch)) { + startIdx--; + } else { + break; + } + } + + // If the character right before caret was not a word char (e.g. space), no word to restore + if (startIdx == endIdx) { + return CaretRestoreResult::NoWordAtCaret; + } + + NSString *wordStr = [textBefore substringWithRange:NSMakeRange((NSUInteger)(startIdx + 1), (NSUInteger)(endIdx - startIdx))]; + if (wordStr.length == 0 || wordStr.length >= MAX_BUFF) { + return CaretRestoreResult::NoWordAtCaret; + } + + // Map characters to engine wordData + std::vector wordData; + wordData.reserve(wordStr.length); + for (NSUInteger i = 0; i < wordStr.length; i++) { + unichar c = [wordStr characterAtIndex:i]; + Uint32 val = lookupReverseChar(c); + if (val == 0xFFFFFFFF) { + return CaretRestoreResult::NoWordAtCaret; + } + wordData.push_back(val); + } + + if (!wordData.empty()) { + loadCurrentTypingWord(wordData); + return CaretRestoreResult::RestoredWord; + } + return CaretRestoreResult::NoWordAtCaret; + } + + struct SuspendedWordState { + std::vector wordData; + NSString *appBundleId; + NSTimeInterval timestamp; + BOOL isValid; + + SuspendedWordState() : appBundleId(nil), timestamp(0), isValid(NO) {} + }; + static SuspendedWordState _suspendedWord; + + void saveSuspendedWordIfNeeded() { + if (isCurrentWordActive()) { + getCurrentTypingWord(_suspendedWord.wordData); + _suspendedWord.appBundleId = FRONT_APP ? [NSString stringWithString:FRONT_APP] : @""; + _suspendedWord.timestamp = [NSDate timeIntervalSinceReferenceDate]; + _suspendedWord.isValid = YES; + } + } + + void clearSuspendedWord() { + _suspendedWord.isValid = NO; + _suspendedWord.wordData.clear(); + _suspendedWord.appBundleId = nil; + } + + bool tryRestoreSuspendedWord(CGKeyCode keycode) { + if (!_suspendedWord.isValid || _suspendedWord.wordData.empty()) return false; + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; + if (now - _suspendedWord.timestamp > 60.0) { + clearSuspendedWord(); + return false; + } + NSString *currApp = FRONT_APP ? FRONT_APP : @""; + if (_suspendedWord.appBundleId.length > 0 && currApp.length > 0 && ![_suspendedWord.appBundleId isEqualToString:currApp]) { + // Keep suspended word intact in case user switches back to target app + return false; + } + if (!isAlphanumericKeyCode(keycode)) { + clearSuspendedWord(); + return false; + } + bool ok = loadCurrentTypingWord(_suspendedWord.wordData); + clearSuspendedWord(); + return ok; + } + void saveSmartSwitchKeyData() { getSmartSwitchKeySaveData(savedSmartSwitchKeyData); NSData* _data = [NSData dataWithBytes:savedSmartSwitchKeyData.data() length:savedSmartSwitchKeyData.size()]; @@ -366,15 +813,31 @@ void SendVirtualKey(const Byte& vKey) { CFRelease(eventVkeyUp); } - void SendBackspace() { - CGEventTapPostEvent(_proxy, eventBackSpaceDown); - CGEventTapPostEvent(_proxy, eventBackSpaceUp); + void SendSingleBackspace(bool isTerminal = false) { + CGEventRef bsDown = CGEventCreateKeyboardEvent(myEventSource, 51, true); + CGEventRef bsUp = CGEventCreateKeyboardEvent(myEventSource, 51, false); + uint64_t now = mach_absolute_time(); + CGEventSetTimestamp(bsDown, now); + CGEventSetTimestamp(bsUp, now); + + CGEventTapPostEvent(_proxy, bsDown); + CGEventTapPostEvent(_proxy, bsUp); + + CFRelease(bsDown); + CFRelease(bsUp); + + if (isTerminal) { + usleep(1500); // 1.5ms inter-keystroke spacing for terminal PTY line discipline + } + } + + void SendBackspace(bool isTerminal = false) { + SendSingleBackspace(isTerminal); if (IS_DOUBLE_CODE(vCodeTable)) { //VNI or Unicode Compound if (_syncKey.back() > 1) { if (!(vCodeTable == 3 && containUnicodeCompoundApp(FRONT_APP))) { - CGEventTapPostEvent(_proxy, eventBackSpaceDown); - CGEventTapPostEvent(_proxy, eventBackSpaceUp); + SendSingleBackspace(isTerminal); } } _syncKey.pop_back(); @@ -495,6 +958,9 @@ void SendNewCharString(const bool& dataFromMacro=false, const Uint16& offset=0) _newEventDown = CGEventCreateKeyboardEvent(myEventSource, 0, true); _newEventUp = CGEventCreateKeyboardEvent(myEventSource, 0, false); + uint64_t now = mach_absolute_time(); + CGEventSetTimestamp(_newEventDown, now); + CGEventSetTimestamp(_newEventUp, now); CGEventKeyboardSetUnicodeString(_newEventDown, _willContinuteSending ? 16 : _newCharSize - offset, _newCharString); CGEventKeyboardSetUnicodeString(_newEventUp, _willContinuteSending ? 16 : _newCharSize - offset, _newCharString); CGEventTapPostEvent(_proxy, _newEventDown); @@ -542,8 +1008,10 @@ void switchLanguage() { } void handleMacro() { + NSString* frontApp = FRONT_APP; + BOOL isTerm = isTerminalApp(frontApp); //fix autocomplete - if (shouldUseRecommendWorkaround(FRONT_APP)) { + if (shouldUseRecommendWorkaround(frontApp)) { SendEmptyCharacter(); pData->backspaceCount++; } @@ -551,7 +1019,10 @@ void handleMacro() { //send backspace if (pData->backspaceCount > 0) { for (int i = 0; i < pData->backspaceCount; i++) { - SendBackspace(); + SendBackspace(isTerm); + } + if (isTerm) { + usleep(1500); } } //send real data @@ -686,6 +1157,9 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e //handle mouse if (type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown || type == kCGEventLeftMouseDragged || type == kCGEventRightMouseDragged) { + if (type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown) { + saveSuspendedWordIfNeeded(); + } RequestNewSession(); return event; } @@ -711,12 +1185,35 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e //handle keyboard if (type == kCGEventKeyDown) { + if (_keycode == KEY_SPACE || _keycode == KEY_RETURN || _keycode == KEY_ENTER || + _keycode == KEY_TAB || _keycode == KEY_ESC || _keycode == KEY_DELETE || + _keycode == KEY_LEFT || _keycode == KEY_RIGHT || _keycode == KEY_UP || _keycode == KEY_DOWN || + OTHER_CONTROL_KEY) { + clearSuspendedWord(); + } + + if (getCurrentWordIndex() == 0 && !OTHER_CONTROL_KEY && isAlphanumericKeyCode(_keycode)) { + CaretRestoreResult res = tryRestoreWordFromCaretContext(); + if (res == CaretRestoreResult::RestoredWord) { + clearSuspendedWord(); + } else if (res == CaretRestoreResult::NoWordAtCaret) { + clearSuspendedWord(); + } else { // Unsupported (Chrome, etc.) + tryRestoreSuspendedWord(_keycode); + } + } //send event signal to Engine vKeyHandleEvent(vKeyEvent::Keyboard, vKeyEventState::KeyDown, _keycode, _flag & kCGEventFlagMaskShift ? 1 : (_flag & kCGEventFlagMaskAlphaShift ? 2 : 0), OTHER_CONTROL_KEY); + + if (isCurrentWordActive()) { + saveSuspendedWordIfNeeded(); + } else if (getCurrentWordIndex() == 0) { + clearSuspendedWord(); + } if (pData->code == vDoNothing) { //do nothing if (IS_DOUBLE_CODE(vCodeTable)) { //VNI if (pData->extCode == 1) { //break key @@ -725,8 +1222,7 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e if (_syncKey.size() > 0) { if (_syncKey.back() > 1 && (vCodeTable == 2 || !containUnicodeCompoundApp(FRONT_APP))) { //send one more backspace - CGEventTapPostEvent(_proxy, eventBackSpaceDown); - CGEventTapPostEvent(_proxy, eventBackSpaceUp); + SendSingleBackspace(isTerminalApp(FRONT_APP)); } _syncKey.pop_back(); } @@ -737,10 +1233,12 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e } return event; } else if (pData->code == vWillProcess || pData->code == vRestore || pData->code == vRestoreAndStartNewSession) { //handle result signal + NSString* currentFrontApp = FRONT_APP; + BOOL isTerm = isTerminalApp(currentFrontApp); //fix autocomplete - if (shouldUseRecommendWorkaround(FRONT_APP) && pData->extCode != 4) { - if (vFixChromiumBrowser && [_unicodeCompoundApp containsObject:FRONT_APP]) { + if (shouldUseRecommendWorkaround(currentFrontApp) && pData->extCode != 4) { + if (vFixChromiumBrowser && [_unicodeCompoundApp containsObject:currentFrontApp]) { if (pData->backspaceCount > 0) { SendShiftAndLeftArrow(); if (pData->backspaceCount == 1) @@ -753,7 +1251,7 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e } } - if (shouldUseSelectionReplacement(FRONT_APP) && pData->backspaceCount > 0) { + if (shouldUseSelectionReplacement(currentFrontApp) && pData->backspaceCount > 0) { for (_i = 0; _i < pData->backspaceCount; _i++) { SendShiftAndLeftArrow(); } @@ -763,7 +1261,10 @@ CGEventRef OpenKeyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef e //send backspace if (pData->backspaceCount > 0 && pData->backspaceCount < MAX_BUFF) { for (_i = 0; _i < pData->backspaceCount; _i++) { - SendBackspace(); + SendBackspace(isTerm); + } + if (isTerm) { + usleep(1500); } } diff --git a/Sources/OpenKey/macOS/OpenKey.xcodeproj/project.pbxproj b/Sources/OpenKey/macOS/OpenKey.xcodeproj/project.pbxproj index 8434abed..84e983ab 100644 --- a/Sources/OpenKey/macOS/OpenKey.xcodeproj/project.pbxproj +++ b/Sources/OpenKey/macOS/OpenKey.xcodeproj/project.pbxproj @@ -504,7 +504,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -558,7 +558,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -580,7 +580,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 2.0.4; OTHER_LDFLAGS = ( "-lc++\n-lc++", @@ -606,7 +606,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 2.0.4; OTHER_LDFLAGS = ( "-lc++\n-lc++", diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..e97d5d9a --- /dev/null +++ b/build.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$DIR/build" + +if [ "$1" == "clean" ]; then + echo "🧹 Đang dọn dẹp thư mục build..." + rm -rf "$BUILD_DIR" + echo "✅ Đã xóa thư mục build." + exit 0 +fi + +echo "🔨 Đang chuẩn bị build OpenKey (Release - Universal Binary)..." +# Xóa bản app cũ để đảm bảo luôn tạo ra bản build mới 100%, không bị lẫn file cũ +rm -rf "$BUILD_DIR/OpenKey.app" + +xcodebuild -project "$DIR/Sources/OpenKey/macOS/OpenKey.xcodeproj" \ + -scheme OpenKey \ + -configuration Release \ + -derivedDataPath "$BUILD_DIR/DerivedData" \ + build \ + CONFIGURATION_BUILD_DIR="$BUILD_DIR" \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGN_STYLE="Manual" + +echo "Build mới thành công tại: $BUILD_DIR/OpenKey.app"