diff --git a/HISTORY.md b/HISTORY.md index 2342633deda..a830311cb89 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,61 @@ # Keyman Version History +## 17.0.226 alpha 2023-12-07 + +* fix(web): accidental suggestion-banner retoggle when disabling predictions (#10014) +* fix(web): Fix null error with legacy keyboards (#10141) + +## 17.0.225 alpha 2023-12-06 + +* feat(developer): Multi-process model for projects (#10114) +* fix(developer): prevent opening .kpj in multiple processes (#10137) +* fix(android/app): InfoActivity pass external links to browser (#10153) + +## 17.0.224 alpha 2023-12-05 + +* chore(linux): Update debian changelog (#10121) + +## 17.0.223 alpha 2023-12-04 + +* chore(web): Add option to create test coverage report file (#10124) + +## 17.0.222 alpha 2023-12-02 + +* fix(common): publish @keymanapp/developer-utils as npm module (#10118) + +## 17.0.221 alpha 2023-12-01 + +* chore(windows): removed cached context windows engine (#10065) +* change(android): flishy flashy mitigation, round 1 (#10017) +* change(android): smoother keyboard initialization (#10022) +* fix(android): no suggestions available when swapping pred-text target language (#10061) +* chore(developer): require project file to exist (#10092) +* chore(linux): Add support for loong64 architecture (#10109) +* fix(web): Don't throw errors after detach (#10086) + +## 17.0.220 alpha 2023-11-30 + +* fix(core): set_if_needed updates an empty cached context (#10098) +* fix(core): check for null termination (#10101) + +## 17.0.219 alpha 2023-11-29 + +* fix(developer): path separator for kmc-package (#10064) +* fix(developer): projects 2.0 internal path enumeration (#10016) +* fix(web): Fix attachment-api tests (#10085) +* fix(web): Also move source map (#10089) + +## 17.0.218 alpha 2023-11-27 + +* feat(developer): ldml: err/hint on illegal/pua chars (#10029) + +## 17.0.217 alpha 2023-11-24 + +* feat(developer): warn on usage of virtual keys in rule output (#10062) +* fix(core): memory management of options in action struct (#10073) +* chore(linux): Update debian changelog (#10047) +* chore(core): Add test keyboard for text selection tests (#10026) + ## 17.0.216 alpha 2023-11-23 * fix(common): kmx struct alignment (#9977) diff --git a/VERSION.md b/VERSION.md index 980da1ad1ae..eb031f65354 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -17.0.217 \ No newline at end of file +17.0.227 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/InfoActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/InfoActivity.java index 250e18ac9c5..3f707501187 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/InfoActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/InfoActivity.java @@ -7,7 +7,9 @@ import com.keyman.engine.BaseActivity; import android.content.Context; +import android.content.Intent; import android.graphics.Bitmap; +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.webkit.WebChromeClient; @@ -87,9 +89,20 @@ public void onReceivedError(WebView view, int errorCode, String description, Str @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url != null && !url.toLowerCase().equals("about:blank")) { + String lowerURL = url.toLowerCase(); + if (lowerURL.equals("about:blank")) { + return true; // never load a blank page, e.g. when the component initializes + } + + Uri uri = Uri.parse(url); + + // All links that aren't internal Keyman help links open in user's browser + if (!url.contains(htmlPath)) { + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(intent); + } else { view.loadUrl(url); - } + } return true; } diff --git a/android/KMEA/app/src/main/assets/android-host.js b/android/KMEA/app/src/main/assets/android-host.js index 7930c76f11f..8c4fd7da8fb 100644 --- a/android/KMEA/app/src/main/assets/android-host.js +++ b/android/KMEA/app/src/main/assets/android-host.js @@ -34,6 +34,10 @@ function init() { keyman.getOskHeight = getOskHeight; keyman.getOskWidth = getOskWidth; keyman.beepKeyboard = beepKeyboard; + + // Readies the keyboard stub for instant loading during the init process. + KeymanWeb.registerStub(JSON.parse(jsInterface.initialKeyboard())); + keyman.init({ 'embeddingApp':device, 'fonts':'packages/', @@ -102,7 +106,7 @@ function setBannerHeight(h) { if (keyman.osk) { keyman.osk.bannerView.activeBannerHeight = bannerHeight; - } + } } // Refresh KMW's OSK @@ -149,8 +153,8 @@ function onStateChange(change) { keyman.refreshOskLayout(); fragmentToggle = (fragmentToggle + 1) % 100; - if(change != 'configured') { // doesn't change the display; only initiates suggestions. - window.location.hash = 'refreshBannerHeight-'+fragmentToggle+'+change='+change; + if(change != 'configured') { + window.location.hash = 'refreshBannerHeight-'+fragmentToggle; } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 215630cd03d..d17c3316971 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -66,7 +66,7 @@ final class KMKeyboard extends WebView { private boolean shouldIgnoreSelectionChange = false; protected KeyboardType keyboardType = KeyboardType.KEYBOARD_TYPE_UNDEFINED; - protected ArrayList javascriptAfterLoad = new ArrayList(); + protected ArrayList javascriptAfterLoad = new ArrayList<>(); private static String currentKeyboard = null; @@ -214,10 +214,8 @@ public boolean onConsoleMessage(ConsoleMessage cm) { } // Send console errors to Sentry in case they're missed by KMW sentryManager - // (Ignoring spurious message "No keyboard stubs exist = ...") - // TODO: Fix base error rather than trying to ignore it "No keyboard stubs exist" - if ((cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) && (!cm.message().startsWith("No keyboard stubs exist"))) { + if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { // Make Toast notification of error and send log about falling back to default keyboard (ignore language ID) // Sanitize sourceId info String NAVIGATION_PATTERN = "^(.*)?(keyboard\\.html#[^-]+)-.*$"; @@ -302,15 +300,21 @@ public void callJavascriptAfterLoad() { this.postDelayed(new Runnable() { @Override public void run() { - if(javascriptAfterLoad.size() > 0) { - loadUrl("javascript:" + javascriptAfterLoad.get(0)); - javascriptAfterLoad.remove(0); - // Make sure we didn't reset the page in the middle of the queue! - if(keyboardSet) { - if (javascriptAfterLoad.size() > 0) { - callJavascriptAfterLoad(); - } - } + StringBuilder allCalls = new StringBuilder(); + if(javascriptAfterLoad.size() == 0) { + return; + } + + while(javascriptAfterLoad.size() > 0) { + String entry = javascriptAfterLoad.remove(0); + allCalls.append(entry); + allCalls.append(";"); + } + + loadUrl("javascript:" + allCalls.toString()); + + if(javascriptAfterLoad.size() > 0 && keyboardSet) { + callJavascriptAfterLoad(); } } }, 1); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardJSHandler.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardJSHandler.java index 6c959b0056c..a8c5291f365 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardJSHandler.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardJSHandler.java @@ -1,6 +1,7 @@ package com.keyman.engine; import android.content.Context; +import android.content.SharedPreferences; import android.os.Build; import android.os.Handler; import android.os.Looper; @@ -20,6 +21,7 @@ import static android.content.Context.VIBRATOR_SERVICE; import com.keyman.engine.KMManager.KeyboardType; +import com.keyman.engine.data.Keyboard; import com.keyman.engine.util.CharSequenceUtil; import com.keyman.engine.util.KMLog; @@ -62,6 +64,21 @@ public int getKeyboardWidth() { return kbWidth; } + @JavascriptInterface + public String initialKeyboard() { + // Note: KMManager.getCurrentKeyboard() (and similar) will throw errors until the host-page is first fully + // loaded and has set a keyboard. To allow the host-page to have earlier access, we instead get the stored + // keyboard index directly. + SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); + int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); + if (index < 0) { + index = 0; + } + + Keyboard kbd = KMManager.getKeyboardInfo(this.context, index); + return kbd.toStub(context); + } + // This annotation is required in Jelly Bean and later: @JavascriptInterface public void beepKeyboard() { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index 4d458bd0616..7a315ff6e1d 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -164,16 +164,13 @@ public boolean shouldOverrideUrlLoading(WebView view, String url) { // for the rest of the lifetime of this keyboard instance. kmKeyboard.setShouldShowHelpBubble(false); } else if (url.indexOf("refreshBannerHeight") >= 0) { - int start = url.indexOf("change=") + 7; - String change = url.substring(start); - boolean isModelActive = change.equals("active"); // appContext instead of context? SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); boolean modelPredictionPref = false; if (KMManager.currentLexicalModel != null) { modelPredictionPref = prefs.getBoolean(KMManager.getLanguagePredictionPreferenceKey(KMManager.currentLexicalModel.get(KMManager.KMKey_LanguageID)), true); } - KMManager.setBannerOptions(isModelActive && modelPredictionPref); + KMManager.setBannerOptions(modelPredictionPref); RelativeLayout.LayoutParams params = KMManager.getKeyboardLayoutParams(); kmKeyboard.setLayoutParams(params); } else if (url.indexOf("suggestPopup") >= 0) { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index df90f1d869d..e2d691d3750 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -776,12 +776,10 @@ public static void onConfigurationChanged(Configuration newConfig) { // KMKeyboard if (InAppKeyboard != null) { RelativeLayout.LayoutParams params = getKeyboardLayoutParams(); - InAppKeyboard.setLayoutParams(params); InAppKeyboard.onConfigurationChanged(newConfig); } if (SystemKeyboard != null) { RelativeLayout.LayoutParams params = getKeyboardLayoutParams(); - SystemKeyboard.setLayoutParams(params); SystemKeyboard.onConfigurationChanged(newConfig); } } @@ -1386,12 +1384,14 @@ public static boolean registerLexicalModel(HashMap lexicalModelI RelativeLayout.LayoutParams params; if (isKeyboardLoaded(KeyboardType.KEYBOARD_TYPE_INAPP) && !InAppKeyboard.shouldIgnoreTextChange() && modelFileExists) { params = getKeyboardLayoutParams(); - InAppKeyboard.setLayoutParams(params); + + // Do NOT re-layout here; it'll be triggered once the banner loads. InAppKeyboard.loadJavascript(KMString.format("enableSuggestions(%s, %s, %s)", model, mayPredict, mayCorrect)); } if (isKeyboardLoaded(KeyboardType.KEYBOARD_TYPE_SYSTEM) && !SystemKeyboard.shouldIgnoreTextChange() && modelFileExists) { params = getKeyboardLayoutParams(); - SystemKeyboard.setLayoutParams(params); + + // Do NOT re-layout here; it'll be triggered once the banner loads. SystemKeyboard.loadJavascript(KMString.format("enableSuggestions(%s, %s, %s)", model, mayPredict, mayCorrect)); } return true; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java index fff24c3b56f..20d414aca8d 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java @@ -51,6 +51,7 @@ import androidx.appcompat.widget.Toolbar; public final class KeyboardPickerActivity extends BaseActivity { + private boolean hasDeleted = false; //TODO: view instances should not be static private static Toolbar toolbar = null; @@ -123,7 +124,7 @@ public void onCreate(Bundle savedInstanceState) { listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { - switchKeyboard(position,dismissOnSelect && ! KMManager.isTestMode()); + switchKeyboard(position); if (dismissOnSelect) finish(); } @@ -140,6 +141,7 @@ public boolean onItemLongClick(AdapterView parent, View view, final int posit public boolean onMenuItemClick(MenuItem item) { if (item.getItemId() == R.id.popup_delete) { deleteKeyboard(context, position); + KeyboardPickerActivity.this.hasDeleted = true; return true; } else { return false; @@ -267,11 +269,16 @@ protected void onResume() { protected void onPause() { super.onPause(); - if (KMManager.InAppKeyboard != null) { - KMManager.InAppKeyboard.loadKeyboard(); - } - if (KMManager.SystemKeyboard != null) { - KMManager.SystemKeyboard.loadKeyboard(); + if (this.hasDeleted) { + this.hasDeleted = false; + + if (KMManager.InAppKeyboard != null) { + KMManager.InAppKeyboard.loadKeyboard(); + } + + if (KMManager.SystemKeyboard != null) { + KMManager.SystemKeyboard.loadKeyboard(); + } } } @@ -335,7 +342,7 @@ private static void setSelection(int position) { * @param position the keyboard index in list * @param aPrepareOnly prepare switch, it is executed on keyboard reload */ - private static void switchKeyboard(int position, boolean aPrepareOnly) { + private static void switchKeyboard(int position) { setSelection(position); int size = KeyboardController.getInstance().get().size(); int listPosition = (position >= size) ? size-1 : position; @@ -344,10 +351,7 @@ private static void switchKeyboard(int position, boolean aPrepareOnly) { String kbId = kbInfo.getKeyboardID(); String langId = kbInfo.getLanguageID(); String kbName = kbInfo.getKeyboardName(); - if(aPrepareOnly) - KMManager.prepareKeyboardSwitch(pkgId, kbId, langId, kbName); - else - KMManager.setKeyboard(kbInfo); + KMManager.setKeyboard(kbInfo); } protected static boolean addKeyboard(Context context, Keyboard keyboardInfo) { @@ -449,7 +453,7 @@ protected static void deleteKeyboard(Context context, int position) { adapter.notifyDataSetChanged(); } if (position == curKbPos) { - switchKeyboard(0,false); + switchKeyboard(0); } else if(listView != null) { // A bit of a hack, since LanguageSettingsActivity calls this method too. curKbPos = KeyboardController.getInstance().getKeyboardIndex(KMKeyboard.currentKeyboard()); setSelection(curKbPos); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 984284fe095..0dbfe5d0239 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -16,11 +16,13 @@ import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.File; import java.io.Serializable; +import java.util.ArrayList; public class Keyboard extends LanguageResource implements Serializable { private static final String TAG = "Keyboard"; @@ -171,6 +173,91 @@ public JSONObject toJSON() { return o; } + private String getKeyboardRoot(Context context) { + String keyboardRoot = context.getDir("data", Context.MODE_PRIVATE).toString() + + File.separator; + + if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { + return keyboardRoot + KMManager.KMDefault_UndefinedPackageID + File.separator; + } else { + return keyboardRoot + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator; + } + } + + public String getKeyboardPath(Context context) { + String keyboardID = this.getKeyboardID(); + String keyboardVersion = this.getVersion(); + if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { + return getKeyboardRoot(context) + keyboardID + "-" + keyboardVersion + ".js"; + } else { + return getKeyboardRoot(context) + keyboardID + ".js"; + } + } + + public String toStub(Context context) { + JSONObject stubObj = new JSONObject(); + + try { + stubObj.put("KN", this.getKeyboardName()); + stubObj.put("KI", "Keyboard_" + this.getKeyboardID()); + stubObj.put("KLC", this.getLanguageID()); + stubObj.put("KL", this.getLanguageName()); + stubObj.put("KF", this.getKeyboardPath(context)); + stubObj.put("KP", this.getPackageID()); + + String displayFont = this.getFont(); + if(displayFont != null) { + stubObj.put("KFont", this.buildDisplayFontObject(displayFont, context)); + } + + String oskFont = this.getOSKFont(); + if(oskFont != null) { + stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont, context)); + } + + String displayName = this.getDisplayName(); + if(displayName != null) { + stubObj.put("displayName", displayName); + } + + return stubObj.toString(); + } catch(JSONException e) { + KMLog.LogException(TAG, "", e); + return null; + } + } + + /** + * Take a font JSON object and adjust to pass to JS + * 1. Replace "source" keys for "files" keys + * 2. Create full font paths for .ttf or .svg + * @param font String font JSON object as a string + * @return JSONObject of modified font information with full paths. If font is invalid, return `null` + */ + private JSONObject buildDisplayFontObject(String font, Context context) { + if(font == null || font.equals("")) { + return null; + } + + String keyboardRoot = this.getKeyboardRoot(context); + + try { + if (FileUtils.hasFontExtension(font)) { + JSONObject jfont = new JSONObject(); + jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length() - 4)); + JSONArray jfiles = new JSONArray(); + jfiles.put(keyboardRoot + font); + jfont.put(KMManager.KMKey_FontFiles, jfiles); + return jfont; + } else { + return null; + } + } catch (JSONException e) { + KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); + return null; + } + } + /** * Get the fallback keyboard. If never specified, use sil_euro_latin * @param context Context diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/logic/ResourcesUpdateTool.java b/android/KMEA/app/src/main/java/com/keyman/engine/logic/ResourcesUpdateTool.java index 1111d92978b..bc749dc2650 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/logic/ResourcesUpdateTool.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/logic/ResourcesUpdateTool.java @@ -562,7 +562,9 @@ private String createLexicalModelId(String theLanguageId, String theModelId) { void tryFinalizeUpdate() { if (openUpdates.isEmpty()) { - + // Trigger a host-page reset - we need to transition to the up-to-date versions. + // TODO: make it smoother. Documented as #11097. + KMManager.clearKeyboardCache(); if (failedUpdateCount > 0) { BaseActivity.makeToast(currentContext, R.string.update_failed, Toast.LENGTH_SHORT); diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/HISTORY.md b/common/test/keyboards/text_selection_tests_keyboard_9073/HISTORY.md new file mode 100644 index 00000000000..e7e7675aa42 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/HISTORY.md @@ -0,0 +1,6 @@ +Text Selection Tests Keyboard Change History +==================== + +1.0 (2023-11-14) +---------------- +* Created by Keyman Team diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/LICENSE.md b/common/test/keyboards/text_selection_tests_keyboard_9073/LICENSE.md new file mode 100644 index 00000000000..f199066a026 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +© 2023 Keyman Team + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/README.md b/common/test/keyboards/text_selection_tests_keyboard_9073/README.md new file mode 100644 index 00000000000..1ab216d60fd --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/README.md @@ -0,0 +1,31 @@ +Text Selection Tests Keyboard keyboard +============== + +Version 1.0 + +Description +----------- +Text Selection Tests Keyboard generated from template + +Links +----- +https://github.com/keymanapp/keyman/issues/9073 + +Copyright +--------- +See [LICENSE.md](LICENSE.md) + +Supported Platforms +------------------- + * Windows + * macOS + * Linux + * Web + * iPhone + * iPad + * Android phone + * Android tablet + * Mobile devices + * Desktop devices + * Tablet devices + diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/readme.htm b/common/test/keyboards/text_selection_tests_keyboard_9073/source/readme.htm new file mode 100644 index 00000000000..1d87395da8d --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/readme.htm @@ -0,0 +1,24 @@ + + + + + + Text Selection Tests Keyboard + + + + +

Text Selection Tests Keyboard

+ +

+ Text Selection Tests Keyboard 1.0 generated from template. +

+ +

© Keyman Team

+ + + diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.ico b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.ico new file mode 100644 index 00000000000..6a5271df0cf Binary files /dev/null and b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.ico differ diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.keyman-touch-layout b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.keyman-touch-layout new file mode 100644 index 00000000000..6d02edfad87 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.keyman-touch-layout @@ -0,0 +1,532 @@ +{ + "tablet": { + "displayUnderlying": false, + "layer": [ + { + "id": "default", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "1", + "nextlayer": "shift" + }, + { + "id": "K_2", + "text": "2" + }, + { + "id": "K_3", + "text": "3" + }, + { + "id": "K_4", + "text": "4" + }, + { + "id": "K_5", + "text": "5" + }, + { + "id": "K_6", + "text": "6" + }, + { + "id": "K_7", + "text": "7" + }, + { + "id": "K_8", + "text": "8" + }, + { + "id": "K_9", + "text": "9" + }, + { + "id": "K_0", + "text": "0" + }, + { + "id": "K_HYPHEN", + "text": "-" + }, + { + "id": "K_EQUAL", + "text": "=" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": 100, + "sp": 1 + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "q", + "pad": 75 + }, + { + "id": "K_W", + "text": "w" + }, + { + "id": "K_E", + "text": "e" + }, + { + "id": "K_R", + "text": "r" + }, + { + "id": "K_T", + "text": "t" + }, + { + "id": "K_Y", + "text": "y" + }, + { + "id": "K_U", + "text": "u" + }, + { + "id": "K_I", + "text": "i" + }, + { + "id": "K_O", + "text": "o" + }, + { + "id": "K_P", + "text": "p" + }, + { + "id": "K_LBRKT", + "text": "[" + }, + { + "id": "K_RBRKT", + "text": "]" + }, + { + "id": "T_new_136", + "width": 10, + "sp": 10 + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "dk(1)" + }, + { + "id": "K_A", + "text": "a" + }, + { + "id": "K_S", + "text": "s" + }, + { + "id": "K_D", + "text": "d" + }, + { + "id": "K_F", + "text": "f" + }, + { + "id": "K_G", + "text": "g" + }, + { + "id": "K_H", + "text": "h" + }, + { + "id": "K_J", + "text": "j" + }, + { + "id": "K_K", + "text": "k" + }, + { + "id": "K_L", + "text": "l" + }, + { + "id": "K_COLON", + "text": ";" + }, + { + "id": "K_QUOTE", + "text": "'" + }, + { + "id": "K_BKSLASH", + "text": "\\" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": 160, + "sp": 1, + "nextlayer": "shift" + }, + { + "id": "K_oE2", + "text": "\\" + }, + { + "id": "K_Z", + "text": "z" + }, + { + "id": "K_X", + "text": "x" + }, + { + "id": "K_C", + "text": "c" + }, + { + "id": "K_V", + "text": "v" + }, + { + "id": "K_B", + "text": "b" + }, + { + "id": "K_N", + "text": "n" + }, + { + "id": "K_M", + "text": "m" + }, + { + "id": "K_COMMA", + "text": "," + }, + { + "id": "K_PERIOD", + "text": "." + }, + { + "id": "K_SLASH", + "text": "/" + }, + { + "id": "T_new_162", + "width": 10, + "sp": 10 + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LOPT", + "text": "*Menu*", + "width": 140, + "sp": 1 + }, + { + "id": "K_SPACE", + "width": 930 + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": 145, + "sp": 1 + } + ] + } + ] + }, + { + "id": "shift", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "!" + }, + { + "id": "K_2", + "text": "@" + }, + { + "id": "K_3", + "text": "#" + }, + { + "id": "K_4", + "text": "$" + }, + { + "id": "K_5", + "text": "%" + }, + { + "id": "K_6", + "text": "^" + }, + { + "id": "K_7", + "text": "&" + }, + { + "id": "K_8", + "text": "*" + }, + { + "id": "K_9", + "text": "(" + }, + { + "id": "K_0", + "text": ")" + }, + { + "id": "K_HYPHEN", + "text": "_" + }, + { + "id": "K_EQUAL", + "text": "+" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": 100, + "sp": 1 + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "Q", + "pad": 75 + }, + { + "id": "K_W", + "text": "W" + }, + { + "id": "K_E", + "text": "E" + }, + { + "id": "K_R", + "text": "R" + }, + { + "id": "K_T", + "text": "T" + }, + { + "id": "K_Y", + "text": "Y" + }, + { + "id": "K_U", + "text": "U" + }, + { + "id": "K_I", + "text": "I" + }, + { + "id": "K_O", + "text": "O" + }, + { + "id": "K_P", + "text": "P" + }, + { + "id": "K_LBRKT", + "text": "{" + }, + { + "id": "K_RBRKT", + "text": "}" + }, + { + "id": "T_new_246", + "width": 10, + "sp": 10 + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "~" + }, + { + "id": "K_A", + "text": "A" + }, + { + "id": "K_S", + "text": "S" + }, + { + "id": "K_D", + "text": "D" + }, + { + "id": "K_F", + "text": "F" + }, + { + "id": "K_G", + "text": "G" + }, + { + "id": "K_H", + "text": "H" + }, + { + "id": "K_J", + "text": "J" + }, + { + "id": "K_K", + "text": "K" + }, + { + "id": "K_L", + "text": "L" + }, + { + "id": "K_COLON", + "text": ":" + }, + { + "id": "K_QUOTE", + "text": "\"" + }, + { + "id": "K_BKSLASH", + "text": "|" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": 160, + "sp": 1, + "nextlayer": "default" + }, + { + "id": "K_oE2", + "text": "|" + }, + { + "id": "K_Z", + "text": "Z" + }, + { + "id": "K_X", + "text": "X" + }, + { + "id": "K_C", + "text": "C" + }, + { + "id": "K_V", + "text": "V" + }, + { + "id": "K_B", + "text": "B" + }, + { + "id": "K_N", + "text": "N" + }, + { + "id": "K_M", + "text": "M" + }, + { + "id": "K_COMMA", + "text": "<" + }, + { + "id": "K_PERIOD", + "text": ">" + }, + { + "id": "K_SLASH", + "text": "?" + }, + { + "id": "T_new_272", + "width": 10, + "sp": 10 + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LOPT", + "text": "*Menu*", + "width": 140, + "sp": 1 + }, + { + "id": "K_SPACE", + "width": 930 + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": 145, + "sp": 1 + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kmn b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kmn new file mode 100644 index 00000000000..b40278a9307 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kmn @@ -0,0 +1,26 @@ +c text_selection_tests_keyboard_9073 generated from template at 2023-11-14 15:23:49 +c with name "Text Selection Tests Keyboard" +store(&VERSION) '10.0' +store(&NAME) 'Text Selection Tests Keyboard' +store(©RIGHT) '© Keyman Team' +store(&KEYBOARDVERSION) '1.0' +store(&TARGETS) 'any' +store(&BITMAP) 'text_selection_tests_keyboard_9073.ico' +store(&VISUALKEYBOARD) 'text_selection_tests_keyboard_9073.kvks' +store(&LAYOUTFILE) 'text_selection_tests_keyboard_9073.keyman-touch-layout' + +begin Unicode > use(main) + +group(main) using keys +'^' + [K_A] > 'â' +'^' + [SHIFT K_A] > 'Â' +'^' + [K_BKSP] > 'foo' + ++ '`' > dk(1) + ++ [K_T] > U+0009 c TAB + +'a' dk(1) 'b' + [K_BKSP] > 'ok1' +'a' 'b' + [K_BKSP] > 'fail1' +'a' dk(1) + [K_BKSP] > 'fail2' +dk(1) + 'o' > 'ok3' diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kps b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kps new file mode 100644 index 00000000000..999160d84d6 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kps @@ -0,0 +1,67 @@ + + + + 16.0.142.0 + 7.0 + + + + readme.htm + + + + + + + + + + Text Selection Tests Keyboard + © Keyman Team + Keyman Team + + + + + ..\build\text_selection_tests_keyboard_9073.kmx + + 0 + .kmx + + + ..\build\text_selection_tests_keyboard_9073.js + + 0 + .js + + + ..\build\text_selection_tests_keyboard_9073.kvk + + 0 + .kvk + + + welcome.htm + + 0 + .htm + + + readme.htm + + 0 + .htm + + + + + Text Selection Tests Keyboard + text_selection_tests_keyboard_9073 + 1.0 + + English + + + + + diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kvks b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kvks new file mode 100644 index 00000000000..9b69397b351 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/text_selection_tests_keyboard_9073.kvks @@ -0,0 +1,110 @@ + + +
+ 10.0 + text_selection_tests_keyboard_9073 + +
+ + + dk(1) + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 0 + - + = + q + w + e + r + t + y + u + i + o + p + [ + ] + \ + a + s + d + f + g + h + j + k + l + ; + ' + \ + z + x + c + v + b + n + m + , + . + / + + + ~ + ! + @ + # + $ + % + ^ + & + * + ( + ) + _ + + + Q + W + E + R + T + Y + U + I + O + P + { + } + | + A + S + D + F + G + H + J + K + L + : + " + | + Z + X + C + V + B + N + M + < + > + ? + + +
diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/source/welcome.htm b/common/test/keyboards/text_selection_tests_keyboard_9073/source/welcome.htm new file mode 100644 index 00000000000..18b821f8c94 --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/source/welcome.htm @@ -0,0 +1,26 @@ + + + + + + Start Using Text Selection Tests Keyboard + + + + +

Start Using Text Selection Tests Keyboard

+ +

+ Text Selection Tests Keyboard 1.0 generated from template. +

+ +

Keyboard Layout

+ + + + + \ No newline at end of file diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.keyboard_info b/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.keyboard_info new file mode 100644 index 00000000000..db0a8bf7bdb --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.keyboard_info @@ -0,0 +1,7 @@ +{ + "license": "mit", + "languages": [ + "en" + ], + "description": "Text Selection Tests Keyboard generated from template" +} diff --git a/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.kpj b/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.kpj new file mode 100644 index 00000000000..98b9ad7e0eb --- /dev/null +++ b/common/test/keyboards/text_selection_tests_keyboard_9073/text_selection_tests_keyboard_9073.kpj @@ -0,0 +1,110 @@ + + + + $PROJECTPATH\build + True + True + True + keyboard + + + + id_dda967022de452e1fe199096e795f0ab + text_selection_tests_keyboard_9073.kmn + source\text_selection_tests_keyboard_9073.kmn + 1.0 + .kmn +
+ Text Selection Tests Keyboard + © Keyman Team +
+
+ + id_ba932837e6a67a86abc409a393242255 + text_selection_tests_keyboard_9073.kps + source\text_selection_tests_keyboard_9073.kps + + .kps +
+ Text Selection Tests Keyboard + © Keyman Team +
+
+ + id_ede98e4633e239f933cbfd1f4e1b766c + HISTORY.md + HISTORY.md + + .md + + + id_53e892b8b41cc4caece1cfd5ef21d6e7 + LICENSE.md + LICENSE.md + + .md + + + id_0730bb7c2e8f9ea2438b52e419dd86c9 + README.md + README.md + + .md + + + id_4b87bd35cc2e16f1ff8680a6f2caed52 + text_selection_tests_keyboard_9073.keyboard_info + text_selection_tests_keyboard_9073.keyboard_info + + .keyboard_info + + + id_bbf31cea8a9cfe0cb838f67055690bf8 + text_selection_tests_keyboard_9073.ico + source\text_selection_tests_keyboard_9073.ico + + .ico + id_dda967022de452e1fe199096e795f0ab + + + id_b8f7a473cac52dd0436273de657cdf46 + text_selection_tests_keyboard_9073.kmx + source\..\build\text_selection_tests_keyboard_9073.kmx + + .kmx + id_ba932837e6a67a86abc409a393242255 + + + id_73d0cd87e78d9b8d7f514809dbb36a47 + text_selection_tests_keyboard_9073.js + source\..\build\text_selection_tests_keyboard_9073.js + + .js + id_ba932837e6a67a86abc409a393242255 + + + id_71aafc060dc3251e4bb611ea539dc8e0 + text_selection_tests_keyboard_9073.kvk + source\..\build\text_selection_tests_keyboard_9073.kvk + + .kvk + id_ba932837e6a67a86abc409a393242255 + + + id_356e5d149c1e539356d72698c1e401a6 + welcome.htm + source\welcome.htm + + .htm + id_ba932837e6a67a86abc409a393242255 + + + id_8da344c4cea6f467013357fe099006f5 + readme.htm + source\readme.htm + + .htm + id_ba932837e6a67a86abc409a393242255 + +
+
diff --git a/common/web/input-processor/src/text/prediction/languageProcessor.ts b/common/web/input-processor/src/text/prediction/languageProcessor.ts index 58c6e429932..c1fdb3a263d 100644 --- a/common/web/input-processor/src/text/prediction/languageProcessor.ts +++ b/common/web/input-processor/src/text/prediction/languageProcessor.ts @@ -127,8 +127,10 @@ export default class LanguageProcessor extends EventEmitter { this.configuration = config; - this._state = 'configured'; - this.emit('statechange', 'configured'); + if(this.mayPredict) { + this._state = 'configured'; + this.emit('statechange', 'configured'); + } }).catch((error) => { // Does this provide enough logging information? let message: string; @@ -423,7 +425,9 @@ export default class LanguageProcessor extends EventEmitter(); /** * Allocate a StrsItem given the string, unescaping if necessary. * @param s escaped string @@ -158,6 +162,11 @@ export class Strs extends Section { // Run the string processing pipeline s = Strs.processString(s, opts, sections); + // add to the set, for testing + if (s) { + this.allProcessedStrings.add(s); + } + // if it's a single char, don't push it into the strs table if (opts?.singleOk && isOneChar(s)) { return new CharStrsItem(s); diff --git a/common/web/types/src/util/util.ts b/common/web/types/src/util/util.ts index ce5ce9ce469..5c8eb70810d 100644 --- a/common/web/types/src/util/util.ts +++ b/common/web/types/src/util/util.ts @@ -101,3 +101,161 @@ toOneChar(value: string) : number { } return value.codePointAt(0); } + +export function describeCodepoint(ch : number) : string { + let s; + const p = getProblem(ch); + if (p != null) { + // for example: 'PUA (U+E010)' + s = p; + } else { + // for example: '"a" (U+61)' + s = `"${String.fromCodePoint(ch)}"`; + } + return `${s} (U+${Number(ch).toString(16).toUpperCase()})`; +} + +export enum BadStringType { + pua = 'PUA', + unassigned = 'Unassigned', + illegal = 'Illegal', +}; + +// Following from kmx_xstring.h / .cpp + +const Uni_LEAD_SURROGATE_START = 0xD800; +const Uni_LEAD_SURROGATE_END = 0xDBFF; +const Uni_TRAIL_SURROGATE_START = 0xDC00; +const Uni_TRAIL_SURROGATE_END = 0xDFFF; +const Uni_SURROGATE_START = Uni_LEAD_SURROGATE_START; +const Uni_SURROGATE_END = Uni_TRAIL_SURROGATE_END; +const Uni_FD_NONCHARACTER_START = 0xFDD0; +const Uni_FD_NONCHARACTER_END = 0xFDEF; +const Uni_FFFE_NONCHARACTER = 0xFFFE; +const Uni_PLANE_MASK = 0x1F0000; +const Uni_MAX_CODEPOINT = 0x10FFFF; +// plane 0, 15, and 16 PUA +const Uni_PUA_00_START = 0xE000; +const Uni_PUA_00_END = 0xF8FF; +const Uni_PUA_15_START = 0x0F0000; +const Uni_PUA_15_END = 0x0FFFFD; +const Uni_PUA_16_START = 0x100000; +const Uni_PUA_16_END = 0x10FFFD; + + +/** + * @brief True if a lead surrogate + * \def Uni_IsSurrogate1 + */ +function Uni_IsSurrogate1(ch : number) { + return ((ch) >= Uni_LEAD_SURROGATE_START && (ch) <= Uni_LEAD_SURROGATE_END); +} +/** + * @brief True if a trail surrogate + * \def Uni_IsSurrogate2 + */ +function Uni_IsSurrogate2(ch : number) { + return ((ch) >= Uni_TRAIL_SURROGATE_START && (ch) <= Uni_TRAIL_SURROGATE_END); +} + +/** + * @brief True if any surrogate + * \def UniIsSurrogate +*/ +function Uni_IsSurrogate(ch : number) { + return (Uni_IsSurrogate1(ch) || Uni_IsSurrogate2(ch)); +} + +function Uni_IsEndOfPlaneNonCharacter(ch : number) { + return (((ch) & Uni_FFFE_NONCHARACTER) == Uni_FFFE_NONCHARACTER); // matches FFFF or FFFE +} + +function Uni_IsNoncharacter(ch : number) { + return (((ch) >= Uni_FD_NONCHARACTER_START && (ch) <= Uni_FD_NONCHARACTER_END) || Uni_IsEndOfPlaneNonCharacter(ch)); +} + +function Uni_InCodespace(ch : number) { + return (ch >= 0 && ch <= Uni_MAX_CODEPOINT); +}; + +function Uni_IsValid1(ch: number) { + return (Uni_InCodespace(ch) && !Uni_IsSurrogate(ch) && !Uni_IsNoncharacter(ch)); +} + +export function isValidUnicode(start: number, end?: number) { + if (!end) { + // single char + return Uni_IsValid1(start); + } else if (!Uni_IsValid1(end) || !Uni_IsValid1(start) || (end < start)) { + // start or end out of range, or inverted range + return false; + } else if ((start <= Uni_SURROGATE_END) && (end >= Uni_SURROGATE_START)) { + // contains some of the surrogate range + return false; + } else if ((start <= Uni_FD_NONCHARACTER_END) && (end >= Uni_FD_NONCHARACTER_START)) { + // contains some of the noncharacter range + return false; + } else if ((start & Uni_PLANE_MASK) != (end & Uni_PLANE_MASK)) { + // start and end are on different planes, meaning that the U+__FFFE/U+__FFFF noncharacters + // are contained. + // As a reminder, we already checked that start/end are themselves valid, + // so we know that 'end' is not on a noncharacter at end of plane. + return false; + } else { + return true; + } +} + +export function isPUA(ch: number) { + return ((ch >= Uni_PUA_00_START && ch <= Uni_PUA_00_END) || + (ch >= Uni_PUA_15_START && ch <= Uni_PUA_15_END) || + (ch >= Uni_PUA_16_START && ch <= Uni_PUA_16_END)); +} + +class BadStringMap extends Map> { + public toString() : string { + if (!this.size) { + return "{}"; + } + return Array.from(this.entries()).map(([t, s]) => `${t}: ${Array.from(s.values()).map(describeCodepoint).join(' ')}`).join(', '); + } +} + +function getProblem(ch : number) : BadStringType { + if (!isValidUnicode(ch)) { + return BadStringType.illegal; + } else if(isPUA(ch)) { + return BadStringType.pua; + } else { // TODO-LDML: unassigned + return null; + } +} +export class BadStringAnalyzer { + /** add a string for analysis */ + public add(s : string) { + for (const c of s) { + const ch = c.codePointAt(0); + const problem = getProblem(ch); + if (problem) { + this.addProblem(ch, problem); + } + } + } + + private addProblem(ch : number, type : BadStringType) { + if (!this.m.has(type)) { + this.m.set(type, new Set()); + } + this.m.get(type).add(ch); + } + + public analyze() : BadStringMap { + if (this.m.size == 0) { + return null; + } else { + return this.m; + } + } + + private m = new BadStringMap(); +} diff --git a/common/web/types/test/util/test-unescape.ts b/common/web/types/test/util/test-unescape.ts index c3630954b36..732e9af2bb9 100644 --- a/common/web/types/test/util/test-unescape.ts +++ b/common/web/types/test/util/test-unescape.ts @@ -1,6 +1,6 @@ import 'mocha'; import {assert} from 'chai'; -import {unescapeString, UnescapeError, isOneChar, toOneChar, unescapeOneQuadString} from '../../src/util/util.js'; +import {unescapeString, UnescapeError, isOneChar, toOneChar, unescapeOneQuadString, BadStringAnalyzer, isValidUnicode, describeCodepoint, isPUA, BadStringType} from '../../src/util/util.js'; describe('test UTF32 functions()', function() { it('should properly categorize strings', () => { @@ -68,3 +68,146 @@ describe('test unescapeOneQuadString()', () => { assert.throws(() => unescapeOneQuadString('\uFFFFFFFFFFFF')); }); }); + +function titleize(o : any) { + const s = JSON.stringify(o); + if (!s) { + return `''`; + } else if (s.length < 10) { + return s; + } else { + return s.substring(0,10)+'…'; + } +} + +describe('test bad char functions', () => { + it('should match test_kmx_xstring.cpp', () => { + function Uni_IsValid(start: number, end?: number) { + return [ start, end ]; + } + function assert_equal(range: number[], expect : boolean) { + const [start, end] = range; + if (end) { + assert.equal(isValidUnicode(start, end), expect, `for ${describeCodepoint(start)}-${describeCodepoint(end)}}`); + } else { + // if branch just for the message + assert.equal(isValidUnicode(start), expect, `for ${describeCodepoint(start)}`); + } + } + // following lines are from test_kmx_xstring.cpp + assert_equal(Uni_IsValid(0x0000), true); + assert_equal(Uni_IsValid(0x0127), true); + assert_equal(Uni_IsValid('🙀'.codePointAt(0)), true); + assert_equal(Uni_IsValid(0xDECAFBAD), false); // out of range + assert_equal(Uni_IsValid(0x566D4128), false); + assert_equal(Uni_IsValid(0xFFFF), false); // nonchar + assert_equal(Uni_IsValid(0xFFFE), false); // nonchar + assert_equal(Uni_IsValid(0x10FFFF), false); // nonchar + assert_equal(Uni_IsValid(0x10FFFE), false); // nonchar + assert_equal(Uni_IsValid(0x01FFFF), false); // nonchar + assert_equal(Uni_IsValid(0x01FFFE), false); // nonchar + assert_equal(Uni_IsValid(0x02FFFF), false); // nonchar + assert_equal(Uni_IsValid(0x02FFFE), false); // nonchar + assert_equal(Uni_IsValid(0xFDD1), false); // nonchar + assert_equal(Uni_IsValid(0xD800), false); // orphaned surrogate + assert_equal(Uni_IsValid(0xFDD0), false); // nonchar + assert_equal(Uni_IsValid(0x100000, 0x10FFFD), true); + assert_equal(Uni_IsValid(0x10, 0x20), true); + assert_equal(Uni_IsValid(0x100000, 0x10FFFD), true); + assert_equal(Uni_IsValid(0x0000, 0xD7FF), true); + assert_equal(Uni_IsValid(0xD800, 0xDFFF), false); // orphaned surrogate + assert_equal(Uni_IsValid(0xE000, 0xFDCF), true); + assert_equal(Uni_IsValid(0xFDD0, 0xFDEF), false); + assert_equal(Uni_IsValid(0xFDF0, 0xFDFF), true); + assert_equal(Uni_IsValid(0xFDF0, 0xFFFD), true); + assert_equal(Uni_IsValid(0, 0x10FFFF), false); // ends with nonchar + assert_equal(Uni_IsValid(0, 0x10FFFD), false); // contains lots o' nonchars + assert_equal(Uni_IsValid(0x20, 0x10), false); // swapped + assert_equal(Uni_IsValid(0xFDEF, 0xFDF0), false); // just outside range + assert_equal(Uni_IsValid(0x0000, 0x010000), false); // crosses noncharacter plane boundary and other stuff + assert_equal(Uni_IsValid(0x010000, 0x020000), false); // crosses noncharacter plane boundary + assert_equal(Uni_IsValid(0x0000, 0xFFFF), false); // crosses other BMP prohibited and plane boundary + assert_equal(Uni_IsValid(0x0000, 0xFFFD), false); // crosses other BMP prohibited + assert_equal(Uni_IsValid(0x0000, 0xE000), false); // crosses surrogate space + assert_equal(Uni_IsValid(0x0000, 0x20FFFF), false); // out of bounds + assert_equal(Uni_IsValid(0x10FFFD, 0x20FFFF), false); // out of bounds + }); + it('should detect non-PUA', () => { + const strs = "abcd" + + ([ + 0xF900, + 0xFFFFF, + ].map(ch => String.fromCodePoint(ch)).join('')); + for (const s of strs) { + const ch = s.codePointAt(0); + assert.isFalse(isPUA(ch), describeCodepoint(ch)); + } + }); + it('should detect PUA', () => { + const strs = "\uE010" + + ([ + 0xE000,0xE001,0xE002, + 0xF000, + 0xF800, + 0xF8FF, + + 0x0F0000, + 0x0FFFFD, + + 0x100000, + 0x10FFFD + ].map(ch => String.fromCodePoint(ch)).join('')); + for (const s of strs) { + const ch = s.codePointAt(0); + assert.isTrue(isPUA(ch), describeCodepoint(ch)); + } + }); +}); + +describe('test BadStringAnalyzer', () => { + describe('should return nothing for all valid strings', () => { + const cases = [ + [], + ['a',], + ['a', 'b',] + ]; + for (const strs of cases) { + const title = titleize(strs); + it(`should analyze ${title}`, () => { + const bsa = new BadStringAnalyzer(); + for (const s of strs) { + bsa.add(s); + } + const m = bsa.analyze(); + assert.isNull(m, `${title}`); + }); + } + }); + describe('should return nothing for all valid strings', () => { + it('should handle a case with some odd strs in it', () => { + const strs = "But you can call me “\uE010\uFDD0\uFFFE\uD800”, for short." + + ([ + 0xF800, + 0x05FFFF, + 0x102222, + 0x04FFFE, + ].map(ch => String.fromCodePoint(ch)).join('')); + + const bsa = new BadStringAnalyzer(); + for (const s of strs) { + bsa.add(s); + } + const m = bsa.analyze(); + assert.isNotNull(m); + assert.containsAllKeys(m, [BadStringType.pua, BadStringType.illegal]); + assert.sameDeepMembers(Array.from(m.get(BadStringType.pua).values()), [ + 0xE010,0xF800, 0x102222, + ], `pua analysis`); + assert.sameDeepMembers(Array.from(m.get(BadStringType.illegal).values()), [ + 0xFDD0,0xD800,0xFFFE, + 0x05FFFF, + 0x04FFFE, + ], `illegal analysis`); + }); + }); +}); diff --git a/common/windows/delphi/general/RegistryKeys.pas b/common/windows/delphi/general/RegistryKeys.pas index 62599851cd2..91a9af51448 100644 --- a/common/windows/delphi/general/RegistryKeys.pas +++ b/common/windows/delphi/general/RegistryKeys.pas @@ -308,6 +308,10 @@ interface SRegKey_IDEVisualKeyboard_CU = SRegKey_IDE_CU + '\VisualKeyboard'; // CU SRegKey_IDEToolbars_CU = SRegKey_IDE_CU + '\Toolbars'; // CU + SRegKey_IDEActiveProjects_CU = SRegKey_IDE_CU + '\Active Projects'; // CU + SRegValue_ActiveProject_Filename = 'project filename'; + SRegValue_ActiveProject_SourcePath = 'source path'; + SRegValue_CheckForUpdates = 'check for updates'; // CU SRegValue_LastUpdateCheckTime = 'last update check time'; // CU diff --git a/common/windows/delphi/general/UserMessages.pas b/common/windows/delphi/general/UserMessages.pas index 0b547891490..1c7fe77ce3b 100644 --- a/common/windows/delphi/general/UserMessages.pas +++ b/common/windows/delphi/general/UserMessages.pas @@ -50,6 +50,7 @@ interface WM_USER_Modified = WM_USER + 124; WM_USER_UpdateCaption = WM_USER + 125; // I4918 + WM_USER_OpenFiles = WM_USER + 126; const // SyntaxHighlight diff --git a/common/windows/delphi/general/utildir.pas b/common/windows/delphi/general/utildir.pas index 727b9f30dcb..b0f22f56578 100644 --- a/common/windows/delphi/general/utildir.pas +++ b/common/windows/delphi/general/utildir.pas @@ -45,13 +45,19 @@ function KGetTempPath: string; function GetLongFileName(const fname: string): string; +function DosSlashes(const filename: string): string; + implementation uses + System.StrUtils, System.SysUtils, Winapi.Windows; - +function DosSlashes(const filename: string): string; +begin + Result := ReplaceStr(filename, '/', '\'); +end; function DirectoryEmpty(dir: WideString): Boolean; var diff --git a/common/windows/delphi/general/utilfiletypes.pas b/common/windows/delphi/general/utilfiletypes.pas index 539a3557b1b..6d873fa9824 100644 --- a/common/windows/delphi/general/utilfiletypes.pas +++ b/common/windows/delphi/general/utilfiletypes.pas @@ -82,6 +82,9 @@ TKMFileTypeInfo = record function GetFileTypeFromFileName(const FileName: string): TKMFileType; function GetFileTypeFilter(ft: TKMFileType; var DefaultExt: string): string; +function IsProjectFile(const FileName: string): Boolean; + +// TODO-LDML: do we need to extend this to support .xml? function IsKeyboardFile(const FileName: string): Boolean; function RemoveFileExtension(Filename, Extension: string): string; @@ -164,6 +167,11 @@ function RemoveFileExtension(Filename, Extension: string): string; else Result := Filename; end; +function IsProjectFile(const FileName: string): Boolean; +begin + Result := SameText(ExtractFileExt(FileName), Ext_ProjectSource); +end; + { TKeymanFileTypeInfo } class function TKeymanFileTypeInfo.IsPackageOptionsFile(const Filename: string): Boolean; diff --git a/core/src/action.cpp b/core/src/action.cpp index 3387d8f85d7..4c018cabbd9 100644 --- a/core/src/action.cpp +++ b/core/src/action.cpp @@ -95,13 +95,15 @@ km_core_actions * km::core::action_item_list_to_actions_object( output.push_back({KM_CORE_CT_MARKER,{0},{action_items->marker}}); break; case KM_CORE_IT_PERSIST_OPT: + { // TODO: lowpri: replace existing item if already present in options vector? - options.push_back(km::core::option( - static_cast(action_items->option->scope), + km::core::option opt(static_cast(action_items->option->scope), action_items->option->key, action_items->option->value - )); + ); + options.push_back(opt.release()); // hand over memory management of the option item to the action struct break; + } default: assert(false); } diff --git a/core/src/km_core_state_api.cpp b/core/src/km_core_state_api.cpp index 8a8165c9c0a..2a25d6fcc72 100644 --- a/core/src/km_core_state_api.cpp +++ b/core/src/km_core_state_api.cpp @@ -262,6 +262,10 @@ void km_core_state_imx_deregister_callback(km_core_state *state) } bool is_context_valid(km_core_cp const * context, km_core_cp const * cached_context) { + if (context == nullptr || cached_context == nullptr || *cached_context == '\0') { + // If the cached_context is "empty" then it needs updating + return false; + } km_core_cp const* context_p = context; while(*context_p) { context_p++; @@ -355,4 +359,4 @@ km_core_status km_core_state_context_clear( } km_core_context_clear(km_core_state_context(state)); return KM_CORE_STATUS_OK; -} \ No newline at end of file +} diff --git a/core/src/kmx/kmx_processor.cpp b/core/src/kmx/kmx_processor.cpp index c72d2fe8577..6790d5b46df 100644 --- a/core/src/kmx/kmx_processor.cpp +++ b/core/src/kmx/kmx_processor.cpp @@ -6,7 +6,7 @@ using namespace km::core; using namespace kmx; -// TODO consolodate with appint.cpp and put in public library. + static KMX_BOOL ContextItemsFromAppContext(KMX_WCHAR *buf, km_core_context_item** outPtr) { assert(buf); diff --git a/core/src/option.cpp b/core/src/option.cpp index dd9242ef0a1..71cb395ddf8 100644 --- a/core/src/option.cpp +++ b/core/src/option.cpp @@ -43,6 +43,14 @@ option::option(km_core_option_scope s, char16_t const *k, char16_t const *v) } } +km_core_option_item +option::release() { + km_core_option_item opt = *this; + key = nullptr; + value = nullptr; + return opt; +} + // TODO: Relocate this and fix it json & km::core::operator << (json &j, abstract_processor const &) { diff --git a/core/src/option.hpp b/core/src/option.hpp index cbfbf764365..43f33970497 100644 --- a/core/src/option.hpp +++ b/core/src/option.hpp @@ -34,10 +34,15 @@ namespace core option & operator=(option const & rhs); option & operator=(option && rhs); + /** + * Returns contents of this object as a C struct, releasing memory + * management of key and value, and invalidates this object. + */ + km_core_option_item release(); + bool empty() const; }; - inline option::option(km_core_option_scope s, std::u16string const & k, std::u16string const & v) diff --git a/core/tests/unit/kmnkbd/action_api.cpp b/core/tests/unit/kmnkbd/action_api.cpp index 79512352a3a..e46d207194a 100644 --- a/core/tests/unit/kmnkbd/action_api.cpp +++ b/core/tests/unit/kmnkbd/action_api.cpp @@ -258,6 +258,27 @@ void test_context_set_if_needed_different_context() { teardown(); } +void test_context_set_if_needed_cached_context_cleared() { + km_core_cp const *application_context = u"This is a test"; + km_core_cp const *cached_context = u""; + setup("k_000___null_keyboard.kmx", cached_context); + km_core_state_context_clear(test_state); + assert(km_core_state_context_set_if_needed(test_state, application_context) == KM_CORE_CONTEXT_STATUS_UPDATED); + assert(!is_identical_context(cached_context)); + assert(is_identical_context(application_context)); + teardown(); +} + +void test_context_set_if_needed_application_context_empty() { + km_core_cp const *application_context = u""; + km_core_cp const *cached_context = u"This is a test"; + setup("k_000___null_keyboard.kmx", cached_context); + assert(km_core_state_context_set_if_needed(test_state, application_context) == KM_CORE_CONTEXT_STATUS_UPDATED); + assert(!is_identical_context(cached_context)); + assert(is_identical_context(application_context)); + teardown(); +} + void test_context_set_if_needed_app_context_is_longer() { km_core_cp const *application_context = u"Longer This is a test"; km_core_cp const *cached_context = u"This is a test"; @@ -319,6 +340,8 @@ void test_context_set_if_needed_cached_context_has_markers() { void test_context_set_if_needed() { test_context_set_if_needed_identical_context(); test_context_set_if_needed_different_context(); + test_context_set_if_needed_cached_context_cleared(); + test_context_set_if_needed_application_context_empty(); test_context_set_if_needed_app_context_is_longer(); test_context_set_if_needed_app_context_is_shorter(); test_context_set_if_needed_cached_context_has_markers(); @@ -374,6 +397,7 @@ int main(int argc, char *argv []) { test_alert(); test_emit_keystroke(); test_invalidate_context(); + test_persist_opt(); // context -- todo move to another file test_context_set_if_needed(); diff --git a/developer/src/common/include/kmn_compiler_errors.h b/developer/src/common/include/kmn_compiler_errors.h index fe7ba11a150..e0bea39549c 100644 --- a/developer/src/common/include/kmn_compiler_errors.h +++ b/developer/src/common/include/kmn_compiler_errors.h @@ -240,6 +240,8 @@ #define CHINT_UnreachableRule 0x000010AE +#define CWARN_VirtualKeyInOutput 0x000020AF + #define CERR_BufferOverflow 0x000080C0 #define CERR_Break 0x000080C1 diff --git a/developer/src/common/web/utils/README.md b/developer/src/common/web/utils/README.md new file mode 100644 index 00000000000..64955e5439a --- /dev/null +++ b/developer/src/common/web/utils/README.md @@ -0,0 +1,10 @@ +# @keymanapp/developer-utils + +This is a helper library for @keymanapp/kmc and other @keymanapp modules, +containing helper functions such as: + +* `spawnChild()` +* Sentry integration +* MIT license text validation + +It is not intended for separate use. \ No newline at end of file diff --git a/developer/src/common/web/utils/build.sh b/developer/src/common/web/utils/build.sh index d0da84b56fe..408e1b03ebf 100755 --- a/developer/src/common/web/utils/build.sh +++ b/developer/src/common/web/utils/build.sh @@ -8,13 +8,17 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" cd "$THIS_SCRIPT_PATH" . "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" +. "$KEYMAN_ROOT/resources/build/build-utils-ci.inc.sh" builder_describe "Build Keyman Developer web utility module" \ "@/common/web/types" \ "clean" \ "configure" \ "build" \ - "test" + "test" \ + publish \ + pack \ + "--dry-run,-n don't actually publish, just dry run" builder_describe_outputs \ configure /node_modules \ @@ -34,3 +38,6 @@ if builder_start_action test; then c8 --reporter=lcov --reporter=text --exclude-after-remap mocha builder_finish_action success test fi + +builder_run_action publish builder_publish_to_npm +builder_run_action pack builder_publish_to_pack diff --git a/developer/src/common/web/utils/package.json b/developer/src/common/web/utils/package.json index b09b667e714..8cd1e5ae0a2 100644 --- a/developer/src/common/web/utils/package.json +++ b/developer/src/common/web/utils/package.json @@ -1,6 +1,5 @@ { "name": "@keymanapp/developer-utils", - "private": true, "description": "Keyman Developer utilities", "type": "module", "exports": { @@ -28,5 +27,14 @@ "require": [ "source-map-support/register" ] + }, + "author": "Marc Durdin (https://github.com/mcdurdin)", + "license": "MIT", + "bugs": { + "url": "https://github.com/keymanapp/keyman/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/keymanapp/keyman.git" } } diff --git a/developer/src/kmc-keyboard-info/package.json b/developer/src/kmc-keyboard-info/package.json index e873d57d7c0..4c0914ee4fa 100644 --- a/developer/src/kmc-keyboard-info/package.json +++ b/developer/src/kmc-keyboard-info/package.json @@ -28,9 +28,6 @@ "@keymanapp/kmc-package": "*", "ttfmeta": "^1.1.2" }, - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "devDependencies": { "@types/chai": "^4.3.5", "@types/mocha": "^5.2.7", diff --git a/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts b/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts index 2995c8f0165..4ad9d025982 100644 --- a/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts +++ b/developer/src/kmc-kmn/src/compiler/kmn-compiler-messages.ts @@ -302,6 +302,8 @@ export class KmnCompilerMessages { static HINT_UnreachableRule = SevHint | 0x0AE; + static WARN_VirtualKeyInOutput = SevWarn | 0x0AF; + static FATAL_BufferOverflow = SevFatal | 0x0C0; static FATAL_Break = SevFatal | 0x0C1; }; diff --git a/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/warn_virtual_key_in_output.kmn b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/warn_virtual_key_in_output.kmn new file mode 100644 index 00000000000..e4919689b10 --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/invalid-keyboards/warn_virtual_key_in_output.kmn @@ -0,0 +1,9 @@ +store(&NAME) 'WARN_VirtualKeyInOutput' +store(&VERSION) '9.0' + +begin Unicode > use(main) + +group(main) using keys + +c WARN_VirtualKeyInOutput ++ 'a' > [K_BKQUOTE] diff --git a/developer/src/kmc-kmn/test/test-messages.ts b/developer/src/kmc-kmn/test/test-messages.ts index 6970b997417..51e6894daa7 100644 --- a/developer/src/kmc-kmn/test/test-messages.ts +++ b/developer/src/kmc-kmn/test/test-messages.ts @@ -87,4 +87,11 @@ describe('CompilerMessages', function () { assert.equal(callbacks.messages[0].message, "Statement 'return' is not currently supported in output for web and touch targets"); }); + // WARN_VirtualKeyInOutput + + it('should generate WARN_VirtualKeyInOutput if a virtual key is found in the output part of a rule', async function() { + await testForMessage(this, ['invalid-keyboards', 'warn_virtual_key_in_output.kmn'], KmnCompilerMessages.WARN_VirtualKeyInOutput); + assert.equal(callbacks.messages[0].message, "Virtual keys are not supported in output"); + }); + }); diff --git a/developer/src/kmc-ldml/src/compiler/compiler.ts b/developer/src/kmc-ldml/src/compiler/compiler.ts index f5f27c0a390..b6f51f553b2 100644 --- a/developer/src/kmc-ldml/src/compiler/compiler.ts +++ b/developer/src/kmc-ldml/src/compiler/compiler.ts @@ -147,8 +147,8 @@ export class LdmlKeyboardCompiler { * @param source * @returns true if the file validates */ - public validate(source: LDMLKeyboardXMLSourceFile): boolean { - return !!this.compile(source); + public async validate(source: LDMLKeyboardXMLSourceFile): Promise { + return !!(await this.compile(source, true)); } /** @@ -157,7 +157,7 @@ export class LdmlKeyboardCompiler { * @param source in-memory representation of LDML keyboard xml file * @returns KMXPlusFile intermediate file */ - public async compile(source: LDMLKeyboardXMLSourceFile): Promise { + public async compile(source: LDMLKeyboardXMLSourceFile, postValidate?: boolean): Promise { const sections = this.buildSections(source); let passed = true; @@ -207,6 +207,15 @@ export class LdmlKeyboardCompiler { kmx.kmxplus[section.id] = sect as any; } + // give all sections a chance to postValidate + if (postValidate) { + for(let section of sections) { + if(!section.postValidate(kmx.kmxplus[section.id])) { + passed = false; + } + } + } + return passed ? kmx : null; } } diff --git a/developer/src/kmc-ldml/src/compiler/empty-compiler.ts b/developer/src/kmc-ldml/src/compiler/empty-compiler.ts index 93476d44970..ac45243734b 100644 --- a/developer/src/kmc-ldml/src/compiler/empty-compiler.ts +++ b/developer/src/kmc-ldml/src/compiler/empty-compiler.ts @@ -1,7 +1,8 @@ import { SectionIdent, constants } from '@keymanapp/ldml-keyboard-constants'; import { SectionCompiler } from "./section-compiler.js"; -import { LDMLKeyboard, KMXPlus, CompilerCallbacks } from "@keymanapp/common-types"; +import { LDMLKeyboard, KMXPlus, CompilerCallbacks, util, MarkerParser } from "@keymanapp/common-types"; import { VarsCompiler } from './vars.js'; +import { CompilerMessages } from './messages.js'; /** * Compiler for typrs that don't actually consume input XML @@ -28,6 +29,45 @@ export class StrsCompiler extends EmptyCompiler { public compile(sections: KMXPlus.DependencySections): KMXPlus.Section { return new KMXPlus.Strs(); } + public postValidate(section?: KMXPlus.Section): boolean { + const strs = section; + + if (strs) { + const badStringAnalyzer = new util.BadStringAnalyzer(); + const CONTAINS_MARKER_REGEX = new RegExp(MarkerParser.ANY_MARKER_MATCH); + for (let s of strs.allProcessedStrings.values()) { + // skip marker strings + if (CONTAINS_MARKER_REGEX.test(s)) { + // it had a marker, take out all marker strings, as the sentinel is illegal + // need a new regex to match + const REPLACE_MARKER_REGEX = new RegExp(MarkerParser.ANY_MARKER_MATCH, 'g'); + s = s.replaceAll(REPLACE_MARKER_REGEX, ''); // remove markers. + } + badStringAnalyzer.add(s); + } + const m = badStringAnalyzer.analyze(); + if (m?.size > 0) { + const puas = m.get(util.BadStringType.pua); + const unassigneds = m.get(util.BadStringType.unassigned); + const illegals = m.get(util.BadStringType.illegal); + if (puas) { + const [count, lowestCh] = [puas.size, Array.from(puas.values()).sort((a, b) => a - b)[0]]; + this.callbacks.reportMessage(CompilerMessages.Hint_PUACharacters({ count, lowestCh })) + } + if (unassigneds) { + const [count, lowestCh] = [unassigneds.size, Array.from(unassigneds.values()).sort((a, b) => a - b)[0]]; + this.callbacks.reportMessage(CompilerMessages.Warn_UnassignedCharacters({ count, lowestCh })) + } + if (illegals) { + // do this last, because we will return false. + const [count, lowestCh] = [illegals.size, Array.from(illegals.values()).sort((a, b) => a - b)[0]]; + this.callbacks.reportMessage(CompilerMessages.Error_IllegalCharacters({ count, lowestCh })) + return false; + } + } + } + return true; + } } export class ElemCompiler extends EmptyCompiler { diff --git a/developer/src/kmc-ldml/src/compiler/messages.ts b/developer/src/kmc-ldml/src/compiler/messages.ts index 640081635c1..750cc38c0f2 100644 --- a/developer/src/kmc-ldml/src/compiler/messages.ts +++ b/developer/src/kmc-ldml/src/compiler/messages.ts @@ -1,5 +1,4 @@ -import { CompilerErrorNamespace, CompilerErrorSeverity, CompilerMessageSpec as m } from "@keymanapp/common-types"; - +import { util, CompilerErrorNamespace, CompilerErrorSeverity, CompilerMessageSpec as m } from "@keymanapp/common-types"; // const SevInfo = CompilerErrorSeverity.Info | CompilerErrorNamespace.LdmlKeyboardCompiler; const SevHint = CompilerErrorSeverity.Hint | CompilerErrorNamespace.LdmlKeyboardCompiler; const SevWarn = CompilerErrorSeverity.Warn | CompilerErrorNamespace.LdmlKeyboardCompiler; @@ -146,6 +145,18 @@ export class CompilerMessages { static Error_DisplayNeedsToOrId = (o:{output?: string, keyId?: string}) => m(this.ERROR_DisplayNeedsToOrId, `display ${CompilerMessages.outputOrKeyId(o)} needs output= or keyId=, but not both`); static ERROR_DisplayNeedsToOrId = SevError | 0x0022; + + static Hint_PUACharacters = (o: { count: number, lowestCh: number }) => + m(this.HINT_PUACharacters, `File contains ${o.count} PUA character(s), including ${util.describeCodepoint(o.lowestCh)}`); + static HINT_PUACharacters = SevHint | 0x0023; + + static Warn_UnassignedCharacters = (o: { count: number, lowestCh: number }) => + m(this.WARN_UnassignedCharacters, `File contains ${o.count} unassigned character(s), including ${util.describeCodepoint(o.lowestCh)}`); + static WARN_UnassignedCharacters = SevWarn | 0x0024; + + static Error_IllegalCharacters = (o: { count: number, lowestCh: number }) => + m(this.ERROR_IllegalCharacters, `File contains ${o.count} illegal character(s), including ${util.describeCodepoint(o.lowestCh)}`); + static ERROR_IllegalCharacters = SevError | 0x0025; } diff --git a/developer/src/kmc-ldml/src/compiler/section-compiler.ts b/developer/src/kmc-ldml/src/compiler/section-compiler.ts index 8744059f7bd..f2b14e56a38 100644 --- a/developer/src/kmc-ldml/src/compiler/section-compiler.ts +++ b/developer/src/kmc-ldml/src/compiler/section-compiler.ts @@ -1,8 +1,9 @@ import { LDMLKeyboard, KMXPlus, CompilerCallbacks } from "@keymanapp/common-types"; import { SectionIdent, constants } from '@keymanapp/ldml-keyboard-constants'; -/* istanbul ignore next */ -export class SectionCompiler { +/** newable interface to SectionCompiler c'tor */ +export type SectionCompilerNew = new (source: LDMLKeyboard.LDMLKeyboardXMLSourceFile, callbacks: CompilerCallbacks) => SectionCompiler; +export abstract class SectionCompiler { protected readonly keyboard3: LDMLKeyboard.LKKeyboard; protected readonly callbacks: CompilerCallbacks; @@ -11,16 +12,32 @@ export class SectionCompiler { this.callbacks = callbacks; } - /* c8 ignore next 11 */ - public get id(): SectionIdent { - throw Error(`Internal Error: id() not implemented`); - } + public abstract get id(): SectionIdent; - public compile(sections: KMXPlus.DependencySections): KMXPlus.Section { - throw Error(`Internal Error: compile() not implemented`); + /** + * This is called before compile. + * @returns false if this compiler failed to validate. + */ + public validate(): boolean { + return true; } - public validate(): boolean { + /** + * Perform the compilation for this section, returning the correct Section subclass + * object. + * + * @param sections any declared dependency sections per dependencies() + */ + public abstract compile(sections: KMXPlus.DependencySections): KMXPlus.Section; + + /** + * This is called after all other compile phases have completed, + * when being called by validate(), and provides an + * opportunity for late error reporting, for example for invalid strings. + * @param section the compiled section, if any. + * @returns false if validate fails + */ + public postValidate(section?: KMXPlus.Section): boolean { return true; } diff --git a/developer/src/kmc-ldml/src/compiler/tran.ts b/developer/src/kmc-ldml/src/compiler/tran.ts index 89a9d61f1cf..2e1944a7cc3 100644 --- a/developer/src/kmc-ldml/src/compiler/tran.ts +++ b/developer/src/kmc-ldml/src/compiler/tran.ts @@ -19,7 +19,7 @@ import { MarkerTracker, MarkerUse } from "./marker-tracker.js"; type TransformCompilerType = 'simple' | 'backspace'; -export class TransformCompiler extends SectionCompiler { +export abstract class TransformCompiler extends SectionCompiler { static validateMarkers(keyboard: LDMLKeyboard.LKKeyboard, mt : MarkerTracker): boolean { keyboard?.transforms?.forEach(transforms => diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml new file mode 100644 index 00000000000..23c85d40230 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/hint-pua.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml new file mode 100644 index 00000000000..a21429d8338 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/invalid-illegal.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml b/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml new file mode 100644 index 00000000000..79f3291dca2 --- /dev/null +++ b/developer/src/kmc-ldml/test/fixtures/sections/strs/warn-unassigned.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/developer/src/kmc-ldml/test/helpers/index.ts b/developer/src/kmc-ldml/test/helpers/index.ts index ea76da94b35..8f6d73ef439 100644 --- a/developer/src/kmc-ldml/test/helpers/index.ts +++ b/developer/src/kmc-ldml/test/helpers/index.ts @@ -4,7 +4,7 @@ import 'mocha'; import * as path from 'path'; import { fileURLToPath } from 'url'; -import { SectionCompiler } from '../../src/compiler/section-compiler.js'; +import { SectionCompiler, SectionCompilerNew } from '../../src/compiler/section-compiler.js'; import { KMXPlus, LDMLKeyboardXMLSourceFileReader, VisualKeyboard, CompilerEvent, LDMLKeyboardTestDataXMLSourceFile, compilerEventFormat, LDMLKeyboard, UnicodeSetParser, CompilerCallbacks } from '@keymanapp/common-types'; import { LdmlKeyboardCompiler } from '../../src/main.js'; // make sure main.js compiles import { assert } from 'chai'; @@ -52,7 +52,7 @@ afterEach(function() { }); -export async function loadSectionFixture(compilerClass: typeof SectionCompiler, filename: string, callbacks: TestCompilerCallbacks, dependencies?: typeof SectionCompiler[]): Promise
{ +export async function loadSectionFixture(compilerClass: SectionCompilerNew, filename: string, callbacks: TestCompilerCallbacks, dependencies?: SectionCompilerNew[], postValidateFail?: boolean): Promise
{ callbacks.messages = []; const inputFilename = makePathToFixture(filename); const data = callbacks.loadFile(inputFilename); @@ -83,13 +83,16 @@ export async function loadSectionFixture(compilerClass: typeof SectionCompiler, compiler.dependencies.forEach(dep => assert.ok(sections[dep], `Required dependency '${dep}' for '${compiler.id}' was not supplied: Check the 'dependencies' argument to loadSectionFixture or testCompilationCases`)); - return compiler.compile(sections); + const section = await compiler.compile(sections); + const postValidate = compiler.postValidate(section); + assert.equal(postValidate, !postValidateFail, `expected postValidate() to return ${!postValidateFail}`); + return section; } /** * Recursively load dependencies. Normally they are loaded in SECTION_COMPILERS order */ -async function loadDepsFor(sections: DependencySections, parentCompiler: SectionCompiler, source: LDMLKeyboardXMLSourceFile, callbacks: TestCompilerCallbacks, dependencies?: typeof SectionCompiler[]) { +async function loadDepsFor(sections: DependencySections, parentCompiler: SectionCompiler, source: LDMLKeyboardXMLSourceFile, callbacks: TestCompilerCallbacks, dependencies?: SectionCompilerNew[]) { const parentId = parentCompiler.id; if (!dependencies) { // default dependencies @@ -115,18 +118,29 @@ export function loadTestdata(inputFilename: string, options: LdmlCompilerOptions return source; } -export async function compileKeyboard(inputFilename: string, options: LdmlCompilerOptions): Promise { +export async function compileKeyboard(inputFilename: string, options: LdmlCompilerOptions, validateMessages?: CompilerEvent[], expectFailValidate?: boolean, compileMessages?: CompilerEvent[]): Promise { const k = new LdmlKeyboardCompiler(compilerTestCallbacks, options); const source = k.load(inputFilename); checkMessages(); assert.isNotNull(source, 'k.load should not have returned null'); - const valid = k.validate(source); - checkMessages(); - assert.isTrue(valid, 'k.validate should not have failed'); + const valid = await k.validate(source); + if (validateMessages) { + assert.sameDeepMembers(compilerTestCallbacks.messages, validateMessages, "validation messages mismatch"); + assert.notEqual(valid, expectFailValidate, 'validation failure'); + } else { + checkMessages(); + assert.isTrue(valid, 'k.validate should not have failed'); + } + + if (!valid) return null; // get out, if the above asserts didn't get us out. const kmx = await k.compile(source); - checkMessages(); + if (compileMessages) { + assert.sameDeepMembers(compilerTestCallbacks.messages, compileMessages, "compiler messages mismatch"); + } else { + checkMessages(); + } assert.isNotNull(kmx, 'k.compile should not have returned null'); // In order for the KMX file to be loaded by non-KMXPlus components, it is helpful @@ -136,13 +150,13 @@ export async function compileKeyboard(inputFilename: string, options: LdmlCompil return kmx; } -export function compileVisualKeyboard(inputFilename: string, options: LdmlCompilerOptions): VisualKeyboard.VisualKeyboard { +export async function compileVisualKeyboard(inputFilename: string, options: LdmlCompilerOptions): Promise { const k = new LdmlKeyboardCompiler(compilerTestCallbacks, options); const source = k.load(inputFilename); checkMessages(); assert.isNotNull(source, 'k.load should not have returned null'); - const valid = k.validate(source); + const valid = await k.validate(source); checkMessages(); assert.isTrue(valid, 'k.validate should not have failed'); @@ -184,7 +198,11 @@ export interface CompilationCase { /** * Optional dependent sections to load. Will be strs+list+elem if falsy. */ - dependencies?: (typeof SectionCompiler)[]; + dependencies?: (SectionCompilerNew)[]; + /** + * Optional, if true, postValidate() must return false. (must be != postValidate()) + */ + postValidateFail?: boolean; } /** @@ -193,7 +211,7 @@ export interface CompilationCase { * @param compiler argument to loadSectionFixture() * @param callbacks argument to loadSectionFixture() */ -export function testCompilationCases(compiler: typeof SectionCompiler, cases : CompilationCase[], dependencies?: (typeof SectionCompiler)[]) { +export function testCompilationCases(compiler: SectionCompilerNew, cases : CompilationCase[], dependencies?: (SectionCompilerNew)[]) { // we need our own callbacks rather than using the global so messages don't get mixed const callbacks = new TestCompilerCallbacks(); for (let testcase of cases) { diff --git a/developer/src/kmc-ldml/test/test-compiler-e2e.ts b/developer/src/kmc-ldml/test/test-compiler-e2e.ts index 343f6cc7ac6..c58d623f33a 100644 --- a/developer/src/kmc-ldml/test/test-compiler-e2e.ts +++ b/developer/src/kmc-ldml/test/test-compiler-e2e.ts @@ -4,10 +4,15 @@ import hextobin from '@keymanapp/hextobin'; import { KMXBuilder } from '@keymanapp/common-types'; import {checkMessages, compileKeyboard, compilerTestCallbacks, compilerTestOptions, makePathToFixture} from './helpers/index.js'; import { LdmlKeyboardCompiler } from '../src/compiler/compiler.js'; +import { CompilerMessages } from '../src/compiler/messages.js'; describe('compiler-tests', function() { this.slow(500); // 0.5 sec -- json schema validation takes a while + before(function() { + compilerTestCallbacks.clear(); + }); + it('should-build-fixtures', async function() { // Let's build basic.xml // It should match basic.kmx (built from basic.txt) @@ -61,4 +66,50 @@ describe('compiler-tests', function() { const source = k.load(filename); assert.notOk(source, `Trying to loadTestData(${filename})`); }); + it('should fail on illegal chars', async function() { + const inputFilename = makePathToFixture('sections/strs/invalid-illegal.xml'); + const kmx = await compileKeyboard(inputFilename, { ...compilerTestOptions, saveDebug: true, shouldAddCompilerVersion: false }, + [ + // validation messages + CompilerMessages.Error_IllegalCharacters({ count: 5, lowestCh: 0xFDD0 }), + CompilerMessages.Hint_PUACharacters({ count: 2, lowestCh: 0xE010 }), + ], + true, // validation should fail + [ + // compiler messages (not reached, we've already failed) + ]); + assert.isNull(kmx); // should fail post-validate + }); + it('should hint on pua chars', async function() { + const inputFilename = makePathToFixture('sections/strs/hint-pua.xml'); + // Compile the keyboard + const kmx = await compileKeyboard(inputFilename, { ...compilerTestOptions, saveDebug: true, shouldAddCompilerVersion: false }, + [ + // validation messages + CompilerMessages.Hint_PUACharacters({ count: 2, lowestCh: 0xE010 }), + ], + false, // validation should pass + [ + // same messages + CompilerMessages.Hint_PUACharacters({ count: 2, lowestCh: 0xE010 }), + ]); + assert.isNotNull(kmx); + }); + it.skip('should warn on unassigned chars', async function() { + // unassigned not implemented yet + const inputFilename = makePathToFixture('sections/strs/warn-unassigned.xml'); + const kmx = await compileKeyboard(inputFilename, { ...compilerTestOptions, saveDebug: true, shouldAddCompilerVersion: false }, + [ + // validation messages + CompilerMessages.Hint_PUACharacters({ count: 2, lowestCh: 0xE010 }), + CompilerMessages.Warn_UnassignedCharacters({ count: 1, lowestCh: 0x0CFFFD }), + ], + false, // validation should pass + [ + // same messages + CompilerMessages.Hint_PUACharacters({ count: 2, lowestCh: 0xE010 }), + CompilerMessages.Warn_UnassignedCharacters({ count: 1, lowestCh: 0x0CFFFD }), + ]); + assert.isNotNull(kmx); + }); }); diff --git a/developer/src/kmc-ldml/test/test-keymanweb-compiler.ts b/developer/src/kmc-ldml/test/test-keymanweb-compiler.ts index ab8c4b2fc22..e1a570ea128 100644 --- a/developer/src/kmc-ldml/test/test-keymanweb-compiler.ts +++ b/developer/src/kmc-ldml/test/test-keymanweb-compiler.ts @@ -22,7 +22,7 @@ describe('LdmlKeyboardKeymanWebCompiler', function() { assert.isNotNull(source, 'k.load should not have returned null'); // Sanity check ... this is also checked in other tests - const valid = k.validate(source); + const valid = await k.validate(source); checkMessages(); assert.isTrue(valid, 'k.validate should not have failed'); diff --git a/developer/src/kmc-ldml/test/test-visual-keyboard-compiler-e2e.ts b/developer/src/kmc-ldml/test/test-visual-keyboard-compiler-e2e.ts index 133f4286c85..df0aea03918 100644 --- a/developer/src/kmc-ldml/test/test-visual-keyboard-compiler-e2e.ts +++ b/developer/src/kmc-ldml/test/test-visual-keyboard-compiler-e2e.ts @@ -15,7 +15,7 @@ describe('visual-keyboard-compiler', function() { const binaryFilename = makePathToFixture('basic-kvk.txt'); // Compile the visual keyboard - const vk = compileVisualKeyboard(inputFilename, {...compilerTestOptions, saveDebug: true, shouldAddCompilerVersion: false}); + const vk = await compileVisualKeyboard(inputFilename, {...compilerTestOptions, saveDebug: true, shouldAddCompilerVersion: false}); assert.isNotNull(vk); // Use the builder to generate the binary output file @@ -28,4 +28,4 @@ describe('visual-keyboard-compiler', function() { let expected = await hextobin(binaryFilename, undefined, {silent:true}); assert.deepEqual(code, expected); }); -}); \ No newline at end of file +}); diff --git a/developer/src/kmc-model-info/package.json b/developer/src/kmc-model-info/package.json index 5f8819e62e0..b8f6cab93da 100644 --- a/developer/src/kmc-model-info/package.json +++ b/developer/src/kmc-model-info/package.json @@ -34,9 +34,6 @@ "@keymanapp/models-types": "*", "@keymanapp/developer-utils": "*" }, - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "devDependencies": { "@types/chai": "^4.1.7", "@types/mocha": "^5.2.7", diff --git a/developer/src/kmc-package/src/compiler/kmp-compiler.ts b/developer/src/kmc-package/src/compiler/kmp-compiler.ts index d795a21d55f..3dce88c9d5b 100644 --- a/developer/src/kmc-package/src/compiler/kmp-compiler.ts +++ b/developer/src/kmc-package/src/compiler/kmp-compiler.ts @@ -140,7 +140,7 @@ export class KmpCompiler { if(kps.Files && kps.Files.File) { kmp.files = this.arrayWrap(kps.Files.File).map((file: KpsFile.KpsFileContentFile) => { return { - name: file.Name.trim(), + name: file.Name.trim().replaceAll('\\','/'), description: file.Description.trim(), copyLocation: parseInt(file.CopyLocation, 10) || undefined // note: we don't emit fileType as that is not permitted in kmp.json diff --git a/developer/src/kmc-package/test/fixtures/withfolders.qaa.sencoten/withfolders.qaa.sencoten.model.kmp.intermediate.json b/developer/src/kmc-package/test/fixtures/withfolders.qaa.sencoten/withfolders.qaa.sencoten.model.kmp.intermediate.json index e02186e8d9b..9d3de2aa64b 100644 --- a/developer/src/kmc-package/test/fixtures/withfolders.qaa.sencoten/withfolders.qaa.sencoten.model.kmp.intermediate.json +++ b/developer/src/kmc-package/test/fixtures/withfolders.qaa.sencoten/withfolders.qaa.sencoten.model.kmp.intermediate.json @@ -21,7 +21,7 @@ }, "files": [ { - "name": "..\\build\\withfolders.qaa.sencoten.model.js", + "name": "../build/withfolders.qaa.sencoten.model.js", "description": "Lexical model withfolders.qaa.sencoten.model.js" }, { diff --git a/developer/src/kmc/build.sh b/developer/src/kmc/build.sh index d000c99c8d5..fdb7cb0cb13 100755 --- a/developer/src/kmc/build.sh +++ b/developer/src/kmc/build.sh @@ -89,6 +89,7 @@ readonly PACKAGES=( common/web/types common/models/types core/include/ldml + developer/src/common/web/utils developer/src/kmc-analyze developer/src/kmc-keyboard-info developer/src/kmc-kmn diff --git a/developer/src/kmc/package.json b/developer/src/kmc/package.json index a7b9d60df2b..d96d8077d7f 100644 --- a/developer/src/kmc/package.json +++ b/developer/src/kmc/package.json @@ -50,9 +50,6 @@ "commander": "^10.0.0", "supports-color": "^9.4.0" }, - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "files": [ "build/src/" ], diff --git a/developer/src/kmcmplib/src/CompMsg.cpp b/developer/src/kmcmplib/src/CompMsg.cpp index 8ab5db81f6b..715750d4f2f 100644 --- a/developer/src/kmcmplib/src/CompMsg.cpp +++ b/developer/src/kmcmplib/src/CompMsg.cpp @@ -143,6 +143,7 @@ const struct CompilerError CompilerErrors[] = { { CWARN_NulNotFirstStatementInContext , "nul must be the first statement in the context"}, { CWARN_IfShouldBeAtStartOfContext , "if, platform and baselayout should be at start of context (after nul, if present)"}, { CWARN_KeyShouldIncludeNCaps , "Other rules which reference this key include CAPS or NCAPS modifiers, so this rule must include NCAPS modifier to avoid inconsistent matches"}, + { CWARN_VirtualKeyInOutput , "Virtual keys are not supported in output"}, { 0, nullptr } }; diff --git a/developer/src/kmcmplib/src/Compiler.cpp b/developer/src/kmcmplib/src/Compiler.cpp index e2720f8608e..045b1c39ad1 100644 --- a/developer/src/kmcmplib/src/Compiler.cpp +++ b/developer/src/kmcmplib/src/Compiler.cpp @@ -1331,6 +1331,7 @@ KMX_BOOL CheckContextStatementPositions(PKMX_WCHAR context) { return TRUE; } + /** * Checks if a use() statement is followed by other content in the output of a rule */ @@ -1348,6 +1349,22 @@ KMX_DWORD CheckUseStatementsInOutput(PKMX_WCHAR output) { return CERR_None; } +/** + * Warn if output has virtual keys in it, which is not supported by Core at all, + * but was unofficially supported, but never worked properly, in Keyman for + * Windows for many years + */ +KMX_DWORD CheckVirtualKeysInOutput(PKMX_WCHAR output) { + PKMX_WCHAR p; + for (p = output; *p; p = incxstr(p)) { + if (*p == UC_SENTINEL && *(p + 1) == CODE_EXTENDED) { + AddWarning(CWARN_VirtualKeyInOutput); + break; + } + } + return CERR_None; +} + /** * Adds implicit `context` to start of output of rules for readonly groups */ @@ -1472,6 +1489,11 @@ KMX_DWORD ProcessKeyLineImpl(PFILE_KEYBOARD fk, PKMX_WCHAR str, KMX_BOOL IsUnico return msg; // I4867 } + // Warn if virtual keys are used in the output, as they are unsupported by Core + if ((msg = CheckVirtualKeysInOutput(pklOut)) != CERR_None) { + return msg; + } + if (gp->fReadOnly) { // Ensure no output is made from the rule, and that // use() statements meet required readonly semantics diff --git a/developer/src/kmconvert/Keyman.Developer.System.KeyboardProjectTemplate.pas b/developer/src/kmconvert/Keyman.Developer.System.KeyboardProjectTemplate.pas index 4846dd49bb3..ce4add77171 100644 --- a/developer/src/kmconvert/Keyman.Developer.System.KeyboardProjectTemplate.pas +++ b/developer/src/kmconvert/Keyman.Developer.System.KeyboardProjectTemplate.pas @@ -199,22 +199,15 @@ procedure TKeyboardProjectTemplate.WriteKPJ; var kpj: TProject; begin - kpj := TProject.Create(ptKeyboard, GetProjectFilename); + kpj := TProject.Create(ptKeyboard, GetProjectFilename, False); try + kpj.Options.Version := pv20; kpj.Options.BuildPath := '$PROJECTPATH\' + SFolder_Build; + kpj.Options.SourcePath := '$PROJECTPATH\' + SFolder_Source; kpj.Options.WarnDeprecatedCode := True; kpj.Options.CompilerWarningsAsErrors := True; kpj.Options.CheckFilenameConventions := True; - - // Add keyboard and package to project - kpj.Files.Add(TkmnProjectFile.Create(kpj, GetKeyboardFilename, nil)); - kpj.Files.Add(TkpsProjectFile.Create(kpj, GetPackageFilename, nil)); - - // Add metadata files to project - kpj.Files.Add(TOpenableProjectFile.Create(kpj, BasePath + ID + '\' + SFile_HistoryMD, nil)); - kpj.Files.Add(TOpenableProjectFile.Create(kpj, BasePath + ID + '\' + SFile_LicenseMD, nil)); - kpj.Files.Add(TOpenableProjectFile.Create(kpj, BasePath + ID + '\' + SFile_ReadmeMD, nil)); - + kpj.Options.SkipMetadataFiles := False; kpj.Save; finally kpj.Free; diff --git a/developer/src/kmconvert/Keyman.Developer.System.ModelProjectTemplate.pas b/developer/src/kmconvert/Keyman.Developer.System.ModelProjectTemplate.pas index 50dfa8bbb83..b5b64f6b86a 100644 --- a/developer/src/kmconvert/Keyman.Developer.System.ModelProjectTemplate.pas +++ b/developer/src/kmconvert/Keyman.Developer.System.ModelProjectTemplate.pas @@ -125,7 +125,7 @@ procedure TModelProjectTemplate.WriteKPJ; var kpj: TProject; begin - kpj := TProject.Create(ptLexicalModel, GetProjectFilename); + kpj := TProject.Create(ptLexicalModel, GetProjectFilename, False); try kpj.Options.BuildPath := '$PROJECTPATH\' + SFolder_Build; kpj.Options.WarnDeprecatedCode := True; diff --git a/developer/src/server/package.json b/developer/src/server/package.json index 122ad1e24f1..8a8b917f98f 100644 --- a/developer/src/server/package.json +++ b/developer/src/server/package.json @@ -38,9 +38,6 @@ "tsc-watch": "^4.5.0", "typescript": "^4.9.5" }, - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "mocha": { "require": "ts-node/register", "spec": "build/**/*.test.js" diff --git a/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.dfm b/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.dfm new file mode 100644 index 00000000000..9ccdf72f821 --- /dev/null +++ b/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.dfm @@ -0,0 +1,66 @@ +object frmMultiProcess: TfrmMultiProcess + Left = 0 + Top = 0 + Caption = 'frmMultiProcess' + ClientHeight = 333 + ClientWidth = 608 + Color = clBtnFace + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -11 + Font.Name = 'Tahoma' + Font.Style = [] + OldCreateOrder = False + OnCreate = FormCreate + PixelsPerInch = 96 + TextHeight = 13 + object Label1: TLabel + Left = 240 + Top = 11 + Width = 31 + Height = 13 + Caption = 'Label1' + end + object Edit1: TEdit + Left = 296 + Top = 8 + Width = 121 + Height = 21 + TabOrder = 0 + Text = 'Edit1' + OnChange = Edit1Change + end + object Button1: TButton + Left = 512 + Top = 8 + Width = 75 + Height = 25 + Caption = 'New Process' + TabOrder = 1 + OnClick = Button1Click + end + object lbProcess: TListBox + Left = 8 + Top = 8 + Width = 209 + Height = 289 + ItemHeight = 13 + TabOrder = 2 + OnDblClick = lbProcessDblClick + end + object cmdFocus: TButton + Left = 8 + Top = 300 + Width = 75 + Height = 25 + Caption = 'Focus process' + TabOrder = 3 + OnClick = cmdFocusClick + end + object tmrEnumerate: TTimer + Interval = 100 + OnTimer = tmrEnumerateTimer + Left = 400 + Top = 112 + end +end diff --git a/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.pas b/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.pas new file mode 100644 index 00000000000..8659992d155 --- /dev/null +++ b/developer/src/test/manual/multiprocess/Keyman.MultiProcess.UI.UfrmMultiProcess.pas @@ -0,0 +1,103 @@ +unit Keyman.MultiProcess.UI.UfrmMultiProcess; + +interface + +uses + Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, + Keyman.Developer.System.MultiProcess, + Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.ExtCtrls; + +type + TfrmMultiProcess = class(TForm) + Edit1: TEdit; + Label1: TLabel; + tmrEnumerate: TTimer; + Button1: TButton; + lbProcess: TListBox; + cmdFocus: TButton; + procedure FormCreate(Sender: TObject); + procedure tmrEnumerateTimer(Sender: TObject); + procedure Edit1Change(Sender: TObject); + procedure lbProcessDblClick(Sender: TObject); + procedure cmdFocusClick(Sender: TObject); + procedure Button1Click(Sender: TObject); + private + function SelectedProcess: TMultiProcessInstance; + { Private declarations } + public + { Public declarations } + end; + +var + frmMultiProcess: TfrmMultiProcess; + +implementation + +uses + utilexecute; + +{$R *.dfm} + +procedure TfrmMultiProcess.Button1Click(Sender: TObject); +begin + TUtilExecute.Execute('"'+ParamStr(0)+'"', ExtractFileDir(ParamStr(0)), SW_SHOWNORMAL); +end; + +procedure TfrmMultiProcess.cmdFocusClick(Sender: TObject); +var + p: TMultiProcessInstance; +begin + p := SelectedProcess; + if Assigned(p) then + p.BringToFront; +end; + +function TfrmMultiProcess.SelectedProcess: TMultiProcessInstance; +begin + if lbProcess.ItemIndex < 0 then + Result := nil + else + Result := TMultiProcessInstance(lbProcess.Items.Objects[lbProcess.ItemIndex]); +end; + +procedure TfrmMultiProcess.Edit1Change(Sender: TObject); +begin + MultiProcessCoordinator.SetProcessIdentifier(Edit1.Text); +end; + +procedure TfrmMultiProcess.FormCreate(Sender: TObject); +begin + Caption := 'MultiProcess Test Form - '+IntToStr(Handle)+'/'+IntToStr(GetCurrentThreadId); +end; + +procedure TfrmMultiProcess.lbProcessDblClick(Sender: TObject); +begin + cmdFocusClick(cmdFocus); +end; + +procedure TfrmMultiProcess.tmrEnumerateTimer(Sender: TObject); +var + p: TMultiProcessInstance; + LastTID: Cardinal; +begin + p := SelectedProcess; + if Assigned(p) then + begin + LastTID := p.ThreadId; + end + else + LastTID := 0; + + lbProcess.Clear; + MultiProcessCoordinator.Enumerate; + for p in MultiProcessCoordinator.Processes do + begin + lbProcess.Items.AddObject( + p.Handle.ToString + '/' + p.ThreadId.ToString + ': '+p.Identifier, + p); + if p.ThreadId = LastTID then + lbProcess.ItemIndex := lbProcess.Items.Count - 1; + end; +end; + +end. diff --git a/developer/src/test/manual/multiprocess/multiprocess.dpr b/developer/src/test/manual/multiprocess/multiprocess.dpr new file mode 100644 index 00000000000..e237458850b --- /dev/null +++ b/developer/src/test/manual/multiprocess/multiprocess.dpr @@ -0,0 +1,18 @@ +program multiprocess; + +uses + Vcl.Forms, + Keyman.MultiProcess.UI.UfrmMultiProcess in 'Keyman.MultiProcess.UI.UfrmMultiProcess.pas' {frmMultiProcess}, + Keyman.Developer.System.MultiProcess in '..\..\..\tike\main\Keyman.Developer.System.MultiProcess.pas', + utilexecute in '..\..\..\..\..\common\windows\delphi\general\utilexecute.pas', + Unicode in '..\..\..\..\..\common\windows\delphi\general\Unicode.pas'; + +{$R *.res} + +begin + CreateMultiProcessCoordinator(TfrmMultiProcess.ClassName, 'Software\Keyman\Test\MultiProcess'); + Application.Initialize; + Application.MainFormOnTaskbar := True; + Application.CreateForm(TfrmMultiProcess, frmMultiProcess); + Application.Run; +end. diff --git a/developer/src/test/manual/multiprocess/multiprocess.dproj b/developer/src/test/manual/multiprocess/multiprocess.dproj new file mode 100644 index 00000000000..ec39e386ab9 --- /dev/null +++ b/developer/src/test/manual/multiprocess/multiprocess.dproj @@ -0,0 +1,984 @@ + + + {6990AF6A-8A22-469D-A1A8-B56DA4C8AE40} + 18.8 + VCL + multiprocess.dpr + True + Debug + Win32 + 1 + Application + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_1 + true + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + .\obj\$(Platform)\$(Config) + .\bin\$(Platform)\$(Config) + false + false + false + false + false + System;Xml;Data;Datasnap;Web;Soap;Vcl;Vcl.Imaging;Vcl.Touch;Vcl.Samples;Vcl.Shell;$(DCC_Namespace) + $(BDS)\bin\delphi_PROJECTICON.ico + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + multiprocess + 3081 + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + + + DBXSqliteDriver;IndyIPCommon;RESTComponents;bindcompdbx;DBXInterBaseDriver;vcl;IndyIPServer;vclactnband;vclFireDAC;IndySystem;tethering;svnui;mbColorLibD10;dsnapcon;FireDACADSDriver;scFontCombo;FireDACMSAccDriver;fmxFireDAC;vclimg;Jcl;FireDAC;vcltouch;JvCore;vcldb;bindcompfmx;svn;FireDACSqliteDriver;FireDACPgDriver;inetdb;CEF4Delphi;soaprtl;DbxCommonDriver;fmx;FireDACIBDriver;fmxdae;xmlrtl;soapmidas;fmxobj;vclwinx;common_components;rtl;DbxClientDriver;CustomIPTransport;vcldsnap;dbexpress;IndyCore;vclx;browse4folder;bindcomp;appanalytics;dsnap;FireDACCommon;IndyIPClient;bindcompvcl;RESTBackendComponents;VCLRESTComponents;soapserver;dbxcds;VclSmp;JvDocking;adortl;developer_components;JclVcl;vclie;bindengine;DBXMySQLDriver;CloudService;dsnapxml;FireDACMySQLDriver;dbrtl;IndyProtocols;inetdbxpress;keyman_components;FireDACCommonODBC;FireDACCommonDriver;inet;fmxase;$(DCC_UsePackage) + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + Debug + true + 1033 + $(BDS)\bin\default_app.manifest + + + DBXSqliteDriver;IndyIPCommon;RESTComponents;bindcompdbx;DBXInterBaseDriver;vcl;IndyIPServer;vclactnband;vclFireDAC;IndySystem;tethering;dsnapcon;FireDACADSDriver;FireDACMSAccDriver;fmxFireDAC;vclimg;Jcl;FireDAC;vcltouch;vcldb;bindcompfmx;FireDACSqliteDriver;FireDACPgDriver;inetdb;CEF4Delphi;soaprtl;DbxCommonDriver;fmx;FireDACIBDriver;fmxdae;xmlrtl;soapmidas;fmxobj;vclwinx;rtl;DbxClientDriver;CustomIPTransport;vcldsnap;dbexpress;IndyCore;vclx;bindcomp;appanalytics;dsnap;FireDACCommon;IndyIPClient;bindcompvcl;RESTBackendComponents;VCLRESTComponents;soapserver;dbxcds;VclSmp;adortl;JclVcl;vclie;bindengine;DBXMySQLDriver;CloudService;dsnapxml;FireDACMySQLDriver;dbrtl;IndyProtocols;inetdbxpress;FireDACCommonODBC;FireDACCommonDriver;inet;fmxase;$(DCC_UsePackage) + + + DEBUG;$(DCC_Define) + true + false + true + true + true + + + false + true + PerMonitorV2 + true + 1033 + + + false + RELEASE;$(DCC_Define) + 0 + 0 + + + true + PerMonitorV2 + true + 1033 + + + + MainSource + + +
frmMultiProcess
+ dfm +
+ + + + + Cfg_2 + Base + + + Base + + + Cfg_1 + Base + +
+ + Delphi.Personality.12 + Application + + + + multiprocess.dpr + + + File c:\projects\keyman\app\windows\lib\DCPdelphi2009.bpl not found + File c:\projects\keyman\app\windows\lib\delphiprojectmanager.bpl not found + Microsoft Office 2000 Sample Automation Server Wrapper Components + Microsoft Office XP Sample Automation Server Wrapper Components + + + + + + multiprocess.exe + true + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + classes + 1 + + + classes + 1 + + + + + res\xml + 1 + + + res\xml + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\armeabi + 1 + + + library\lib\armeabi + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\mips + 1 + + + library\lib\mips + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-v21 + 1 + + + res\values-v21 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-ldpi + 1 + + + res\drawable-ldpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-small + 1 + + + res\drawable-small + 1 + + + + + res\drawable-normal + 1 + + + res\drawable-normal + 1 + + + + + res\drawable-large + 1 + + + res\drawable-large + 1 + + + + + res\drawable-xlarge + 1 + + + res\drawable-xlarge + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + 0 + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .dll;.bpl + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .bpl + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + 0 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + 1 + + + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + + + Contents + 1 + + + Contents + 1 + + + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + library\lib\armeabi-v7a + 1 + + + + + 1 + + + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + + + + + + + + + + + True + False + + + 12 + + + + +
diff --git a/developer/src/test/manual/multiprocess/multiprocess.res b/developer/src/test/manual/multiprocess/multiprocess.res new file mode 100644 index 00000000000..f044246c185 Binary files /dev/null and b/developer/src/test/manual/multiprocess/multiprocess.res differ diff --git a/developer/src/tike/actions/dmActionsMain.dfm b/developer/src/tike/actions/dmActionsMain.dfm index 09890f90613..ac5546a95dd 100644 --- a/developer/src/tike/actions/dmActionsMain.dfm +++ b/developer/src/tike/actions/dmActionsMain.dfm @@ -32,17 +32,6 @@ object modActionsMain: TmodActionsMain OnExecute = actViewCharacterIdentifierExecute OnUpdate = actViewCharacterIdentifierUpdate end - object actProjectOpenFolder: TBrowseForFolder - Category = 'Project' - Caption = 'Open Project Folder...' - DialogCaption = 'Open Project Folder' - BrowseOptions = [] - BrowseOptionsEx = [] - Hint = 'Open Project Folder|Opens an existing project folder' - ShortCut = 24655 - UseFileDialog = True - OnAccept = actProjectOpenFolderAccept - end object actFileOpen: TFileOpen Category = 'File' Caption = '&Open...' @@ -259,6 +248,7 @@ object modActionsMain: TmodActionsMain Dialog.Options = [ofHideReadOnly, ofPathMustExist, ofFileMustExist, ofEnableSizing] Hint = 'Open Project|Opens an existing project' ImageIndex = 28 + ShortCut = 24655 OnAccept = actProjectOpenAccept end object actProjectAddCurrentEditorFile: TAction diff --git a/developer/src/tike/actions/dmActionsMain.pas b/developer/src/tike/actions/dmActionsMain.pas index 2230d89c58b..eff73c4d8ec 100644 --- a/developer/src/tike/actions/dmActionsMain.pas +++ b/developer/src/tike/actions/dmActionsMain.pas @@ -138,7 +138,6 @@ TmodActionsMain = class(TDataModule) actToolsWebConfigure: TAction; actToolsWebStartServer: TAction; actToolsWebStopServer: TAction; - actProjectOpenFolder: TBrowseForFolder; procedure actFileNewExecute(Sender: TObject); procedure DataModuleCreate(Sender: TObject); procedure actFileOpenAccept(Sender: TObject); @@ -238,7 +237,6 @@ TmodActionsMain = class(TDataModule) procedure actToolsWebStartServerUpdate(Sender: TObject); procedure actToolsWebStopServerExecute(Sender: TObject); procedure actToolsWebStopServerUpdate(Sender: TObject); - procedure actProjectOpenFolderAccept(Sender: TObject); private function CheckFilenameConventions(FileName: string): Boolean; function SaveAndCloseAllFiles: Boolean; @@ -336,11 +334,8 @@ procedure TmodActionsMain.actFileNewUpdate(Sender: TObject); end; procedure TmodActionsMain.actFileOpenAccept(Sender: TObject); -var - i: Integer; begin - for i := 0 to actFileOpen.Dialog.Files.Count - 1 do - frmKeymanDeveloper.OpenFile(actFileOpen.Dialog.Files[i], True); + frmKeymanDeveloper.OpenFilesInProject(actFileOpen.Dialog.Files.ToStringArray); end; procedure TmodActionsMain.actFileOpenUpdate(Sender: TObject); @@ -581,6 +576,7 @@ procedure TmodActionsMain.actProjectCloseUpdate(Sender: TObject); procedure TmodActionsMain.actProjectNewExecute(Sender: TObject); begin + // TODO: new process if not frmKeymanDeveloper.BeforeOpenProject then Exit; ShowNewProjectForm(frmKeymanDeveloper); @@ -588,22 +584,13 @@ procedure TmodActionsMain.actProjectNewExecute(Sender: TObject); procedure TmodActionsMain.actProjectOpenAccept(Sender: TObject); begin - if not frmKeymanDeveloper.BeforeOpenProject then - Exit; - OpenProject(actProjectOpen.Dialog.FileName); -end; - -procedure TmodActionsMain.actProjectOpenFolderAccept(Sender: TObject); -begin - if not frmKeymanDeveloper.BeforeOpenProject then - Exit; - OpenProject(actProjectOpenFolder.Folder); + frmKeymanDeveloper.OpenProject(actProjectOpen.Dialog.FileName); end; procedure TmodActionsMain.OpenProject(FileName: WideString); begin FileName := ExpandUNCFileName(FileName); - if (FileName <> '') and not FileExists(FileName) and not DirectoryExists(FileName) then + if (FileName <> '') and not FileExists(FileName) then begin ShowMessage('The project '+FileName+' does not exist.'); Exit; @@ -1056,9 +1043,9 @@ procedure TmodActionsMain.actWindowPrevUpdate(Sender: TObject); procedure TmodActionsMain.DataModuleCreate(Sender: TObject); begin actFileOpen.Dialog.Filter := - 'Keyman source files|*.kmn;*.kps;*.txt;*.bmp;*.ico;*.kpj;*.kvk;*.kvks;*.model.ts;*.tsv|'+ + 'Keyman source files|*.kmn;*.kps;*.txt;*.bmp;*.ico;*.kpj;*.kvk;*.kvks;*.model.ts;*.tsv;*.xml|'+ 'Projects files (*.kpj)|*.kpj|'+ - 'Keyboard files (*.kmn)|*.kmn|'+ + 'Keyboard files (*.kmn, *.xml)|*.kmn;*.xml|'+ 'Package files (*.kps)|*.kps|'+ 'Model files (*.model.ts)|*.model.ts|'+ 'Wordlist files (*.tsv)|*.tsv|'+ diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.App.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.App.pas index 1af9aafb976..b438efc916b 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.App.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.App.pas @@ -43,6 +43,7 @@ implementation JsonUtil, KeymanDeveloperOptions, + Keyman.Developer.System.Project.Project, Keyman.Developer.System.Project.ProjectFile, Keyman.Developer.System.Project.WelcomeRenderer, RedistFiles; @@ -112,7 +113,7 @@ procedure TAppHttpResponder.RespondProject(doc: string; AContext: TIdContext; end; // Transform the .kpj - with TProject.Create(ptUnknown, path) do + with TProject.Create(ptUnknown, path, True) do try AResponseInfo.ContentType := 'text/html; charset=UTF-8'; AResponseInfo.ContentText := Render; diff --git a/developer/src/tike/main/DropTarget.pas b/developer/src/tike/main/DropTarget.pas index 7edc770524c..e7236ad98d7 100644 --- a/developer/src/tike/main/DropTarget.pas +++ b/developer/src/tike/main/DropTarget.pas @@ -15,8 +15,8 @@ interface type IDragDrop = interface - function DropAllowed(const FileNames: array of string): Boolean; - procedure Drop(const FileNames: array of string); + function DropAllowed(const FileNames: TArray): Boolean; + procedure Drop(const FileNames: TArray); end; TDropTarget = class(TObject, IInterface, IDropTarget) diff --git a/developer/src/tike/main/Keyman.Developer.System.LaunchProjects.pas b/developer/src/tike/main/Keyman.Developer.System.LaunchProjects.pas new file mode 100644 index 00000000000..376ac4fb8e0 --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.LaunchProjects.pas @@ -0,0 +1,183 @@ +unit Keyman.Developer.System.LaunchProjects; + +interface + +uses + System.Classes, + System.Generics.Collections, + System.SysUtils, + + Keyman.Developer.System.TikeMultiProcess; + +type + TLaunchProjectStatus = (lpsWaiting, lpsCurrentInstance, lpsOtherInstance, lpsNewInstance, lpsError); + TLaunchProject = class + private + FProjectFilename: string; + FFilenames: TStringList; + FStatus: TLaunchProjectStatus; + public + constructor Create(const AProjectFilename: string); + destructor Destroy; override; + function LaunchAsNewInstance: Boolean; + function PassToRunningProcess(AProcesses: TTikeProcessList): Boolean; + property ProjectFilename: string read FProjectFilename; + property Filenames: TStringList read FFilenames; + property Status: TLaunchProjectStatus read FStatus; + end; + + TLaunchProjects = class(TObjectList) + private + FReserveStartupProject: Boolean; + function GetStartupProject: TLaunchProject; + public + constructor Create(AReserveStartupProject: Boolean); + procedure GroupFilenamesIntoProjects(const Filenames: TStringList); + function LaunchAll(AProcesses: TTikeProcessList): Boolean; + function Find(const ProjectFilename: string): TLaunchProject; + property ReserveStartupProject: Boolean read FReserveStartupProject; + property StartupProject: TLaunchProject read GetStartupProject; + end; + +implementation + +uses + Winapi.Windows, + + Keyman.Developer.System.ProjectOwningFile, + utilexecute; + +{ TLaunchProject } + +constructor TLaunchProject.Create(const AProjectFilename: string); +begin + inherited Create; + FProjectFilename := AProjectFilename; + FFilenames := TStringList.Create; + FStatus := lpsWaiting; +end; + +destructor TLaunchProject.Destroy; +begin + FFilenames.Free; + inherited Destroy; +end; + +function TLaunchProject.LaunchAsNewInstance: Boolean; +var + filename, cmdline: string; +begin + cmdline := '"'+ParamStr(0)+'" --sub-process "'+Self.ProjectFilename+'"'; + for filename in Self.Filenames do + cmdline := cmdline + ' "'+filename+'"'; + + Result := TUtilExecute.Execute(cmdline, GetCurrentDir, SW_SHOWNORMAL); + + if Result + then FStatus := lpsNewInstance + else FStatus := lpsError; +end; + +function TLaunchProject.PassToRunningProcess(AProcesses: TTikeProcessList): Boolean; +var + tp: TTikeProcess; + filename: string; +begin + for tp in AProcesses do + begin + if tp.OwnsProject(Self.ProjectFilename) then + begin + FStatus := lpsOtherInstance; + + if Self.Filenames.Count = 0 then + begin + // Ensure that the project file tab is opened + tp.OpenFile(Self.ProjectFilename); + end; + + for filename in Self.Filenames do + begin + tp.OpenFile(filename); + end; + Exit(True); + end; + end; + Result := False; +end; + +{ TLaunchProjects } + +constructor TLaunchProjects.Create(AReserveStartupProject: Boolean); +begin + inherited Create(True); + FReserveStartupProject := AReserveStartupProject; +end; + +function TLaunchProjects.Find(const ProjectFilename: string): TLaunchProject; +begin + for Result in Self do + begin + if Result.ProjectFilename = ProjectFilename then + begin + Exit; + end; + end; + Result := nil; +end; + +function TLaunchProjects.GetStartupProject: TLaunchProject; +begin + for Result in Self do + if Result.Status = lpsCurrentInstance then + Exit; + Result := nil; +end; + +procedure TLaunchProjects.GroupFilenamesIntoProjects(const Filenames: TStringList); +var + projectFilename, filename: string; + p: TLaunchProject; +begin + for filename in filenames do + begin + projectFilename := FindOwnerProjectForFile(filename); + + p := Self.Find(projectFilename); + if p = nil then + begin + p := TLaunchProject.Create(projectFilename); + Self.Add(p); + end; + p.Filenames.Add(filename); + end; +end; + +function TLaunchProjects.LaunchAll(AProcesses: TTikeProcessList): Boolean; +var + p: TLaunchProject; +begin + Result := False; + + // Hand off files to existing processes, based on project folder + for p in Self do + begin + if not p.PassToRunningProcess(AProcesses) then + begin + // If there isn't an existing process, then we need to launch a new + // process for the project + if not Result and ReserveStartupProject then + begin + // For performance, we'll take the first project for the current process + // if requested + p.FStatus := lpsCurrentInstance; + Result := True; + end + else + begin + p.LaunchAsNewInstance; + end; + end; + end; +end; + +end. diff --git a/developer/src/tike/main/Keyman.Developer.System.Main.pas b/developer/src/tike/main/Keyman.Developer.System.Main.pas new file mode 100644 index 00000000000..43cb5c674da --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.Main.pas @@ -0,0 +1,87 @@ +unit Keyman.Developer.System.Main; + +interface + +uses + System.SysUtils, + System.Win.ComObj, + Vcl.Forms, + Winapi.ActiveX, + Winapi.UxTheme, + + uCEFApplication, + uCEFTypes, + + Keyman.Developer.System.TikeCommandLine, + Keyman.System.CEFManager, + Keyman.System.KeymanSentryClient, + Sentry.Client, + Sentry.Client.Vcl, + UfrmMain, + UmodWebHttpServer; + +procedure RunKeymanDeveloper; + +implementation + +const + LOGGER_DEVELOPER_IDE_TIKE = TKeymanSentryClient.LOGGER_DEVELOPER_IDE + '.tike'; + +procedure RunWithExceptionsHandled; forward; + +procedure RunKeymanDeveloper; +begin + CoInitFlags := COINIT_APARTMENTTHREADED; + Application.MainFormOnTaskBar := True; + Application.Initialize; + Application.Title := 'Keyman Developer'; + + TKeymanSentryClient.Start(TSentryClientVcl, kscpDeveloper, LOGGER_DEVELOPER_IDE_TIKE); + try + try + RunWithExceptionsHandled; + except + on E:Exception do + SentryHandleException(E); + end; + finally + TKeymanSentryClient.Stop; + end; +end; + +procedure RunWithExceptionsHandled; +begin + FInitializeCEF := TCEFManager.Create; + try + if GlobalCEFApp.ProcessType = ptBrowser then + begin + // We want to process the command line only if we are not a CEF + // sub-process, because otherwise we lose the benefit of + if TikeCommandLine.Process = pclExit then + begin + Exit; + end; + end; + + if FInitializeCEF.Start then + begin + InitThemeLibrary; + SetThemeAppProperties(STAP_ALLOW_NONCLIENT or STAP_ALLOW_CONTROLS or STAP_ALLOW_WEBCONTENT); + Application.CreateForm(TmodWebHttpServer, modWebHttpServer); + try + Application.CreateForm(TfrmKeymanDeveloper, frmKeymanDeveloper); + try + Application.Run; + finally + FreeAndNil(frmKeymanDeveloper); + end; + finally + FreeAndNil(modWebHttpServer); + end; + end; + finally + FInitializeCEF.Free; + end; +end; + +end. diff --git a/developer/src/tike/main/Keyman.Developer.System.MultiProcess.pas b/developer/src/tike/main/Keyman.Developer.System.MultiProcess.pas new file mode 100644 index 00000000000..57534a943d3 --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.MultiProcess.pas @@ -0,0 +1,225 @@ +unit Keyman.Developer.System.MultiProcess; + +interface + +uses + System.Generics.Collections, + + System.Win.Registry; + +type + TMultiProcessInstance = class + ThreadId: Cardinal; + Handle: THandle; + Identifier: string; + end; + + TMultiProcessCoordinator = class + private + FWindowHandles: TList; + FProcesses: TObjectList; + FParentWindowClassName: string; + FRegistryKey: string; + function EnumWindowsProc(hwnd: THandle): Boolean; + function OpenActiveProcessKey: TRegistry; + procedure ClearProcessIdentifier; + function CurrentProcessValueName: string; + public + constructor Create(const ParentWindowClassName, RegistryKey: string); + destructor Destroy; override; + procedure Enumerate; + procedure SetProcessIdentifier(const Identifier: string); + procedure CleanupStaleRegisteredInstances; + + property Processes: TObjectList read FProcesses; + end; + +function MultiProcessCoordinator: TMultiProcessCoordinator; +procedure CreateMultiProcessCoordinator(ParentWindowClassName, RegistryKey: string); + +implementation + +uses + System.Classes, + System.SysUtils, + Winapi.Windows; + +{ TMultiProcessCoordinator } + +constructor TMultiProcessCoordinator.Create(const ParentWindowClassName, RegistryKey: string); +begin + inherited Create; + FParentWindowClassName := ParentWindowClassName; + FRegistryKey := RegistryKey; + FWindowHandles := TList.Create; + FProcesses := TObjectList.Create; + SetProcessIdentifier(''); +end; + +destructor TMultiProcessCoordinator.Destroy; +begin + ClearProcessIdentifier; + FWindowHandles.Free; + FProcesses.Free; + inherited Destroy; +end; + +function _enumwindowsproc(hwnd: THandle; lParam: LPARAM): BOOL; stdcall; +begin + Result := TMultiProcessCoordinator(lParam).EnumWindowsProc(hwnd); +end; + +procedure TMultiProcessCoordinator.Enumerate; +var + h: THandle; + reg: TRegistry; + p: TMultiProcessInstance; + tid: DWord; +begin + // Looks for windows with the matching class name, and then + // matches those with running instances + + FWindowHandles.Clear; + FProcesses.Clear; + EnumWindows(@_enumwindowsproc, LPARAM(Self)); + + reg := OpenActiveProcessKey; + try + for h in FWindowHandles do + begin + tid := GetWindowThreadProcessId(h); + if (tid <> 0) and reg.ValueExists(IntToStr(tid)) then + begin + p := TMultiProcessInstance.Create; + p.ThreadId := tid; + p.Handle := h; + p.Identifier := reg.ReadString(IntToStr(tid)); + FProcesses.Add(p); + end; + end; + finally + reg.Free; + end; +end; + +function TMultiProcessCoordinator.EnumWindowsProc(hwnd: THandle): Boolean; +var + szbuf: array[0..32] of char; +begin + Result := True; + + if GetClassName(hwnd, szbuf, 32) = 0 then + Exit; + + if FParentWindowClassName <> szbuf then + Exit; + + FWindowHandles.Add(hwnd); +end; + +/// Looks for windows with the matching class name, and then +/// matches those with running instances. Stale instances can be left +/// if an instance crashes or if Windows does not shutdown cleanly. +/// There is a slight risk of a race here, if a new process is started +/// between window enumeration and the loop in this function, but it is +/// not worth introducing the extra complexity to avoid this rare situation. +procedure TMultiProcessCoordinator.CleanupStaleRegisteredInstances; + function ProcessListHasTid(tid: Cardinal): Boolean; + var + p: TMultiProcessInstance; + begin + for p in FProcesses do + begin + if p.ThreadId = tid then + begin + Exit(True); + end; + end; + Result := False; + end; +var + reg: TRegistry; + tidString: string; + tid: Cardinal; + tids: TStrings; +begin + Enumerate; + + reg := OpenActiveProcessKey; + tids := TStringList.Create; + try + reg.GetValueNames(tids); + for tidString in tids do + begin + tid := StrToIntDef(tidString, 0); + if not ProcessListHasTid(tid) then + begin + reg.DeleteValue(tidString); + end; + end; + finally + tids.Free; + reg.Free; + end; +end; + +procedure TMultiProcessCoordinator.SetProcessIdentifier(const Identifier: string); +var + reg: TRegistry; +begin + reg := OpenActiveProcessKey; + try + reg.WriteString(CurrentProcessValueName, Identifier); + finally + reg.Free; + end; +end; + +procedure TMultiProcessCoordinator.ClearProcessIdentifier; +var + reg: TRegistry; +begin + reg := OpenActiveProcessKey; + try + if reg.ValueExists(CurrentProcessValueName) then + reg.DeleteValue(CurrentProcessValueName); + finally + reg.Free; + end; +end; + +function TMultiProcessCoordinator.OpenActiveProcessKey: TRegistry; +begin + Result := TRegistry.Create; + if not Result.OpenKey(FRegistryKey, True) then + begin + FreeAndNil(Result); + RaiseLastOSError; + end; +end; + +function TMultiProcessCoordinator.CurrentProcessValueName: string; +begin + Result := IntToStr(GetCurrentThreadId); +end; + +//------------------------------------------------------------------------------ + +var + FInstance: TMultiProcessCoordinator = nil; + +function MultiProcessCoordinator: TMultiProcessCoordinator; +begin + Result := FInstance; +end; + +procedure CreateMultiProcessCoordinator(ParentWindowClassName, RegistryKey: string); +begin + Assert(FInstance = nil); + FInstance := TMultiProcessCoordinator.Create(ParentWindowClassName, RegistryKey); +end; + +initialization +finalization + FreeAndNil(FInstance); +end. diff --git a/developer/src/tike/main/Keyman.Developer.System.ProjectOwningFile.pas b/developer/src/tike/main/Keyman.Developer.System.ProjectOwningFile.pas new file mode 100644 index 00000000000..b13a79e396c --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.ProjectOwningFile.pas @@ -0,0 +1,82 @@ +unit Keyman.Developer.System.ProjectOwningFile; + +interface + +function FindOwnerProjectForFile(const filename: string): string; + +implementation + +uses + System.SysUtils, + + Keyman.Developer.System.Project.ProjectFile; + +function CheckOwnerProjectForFile(const project, filename: string): Boolean; forward; + +{ + #2761 + + Given an arbitrary/path/to/file.kmn, search for a project: + [* arbitrary/path/to/keyman.kpj] + * arbitrary/path/to/to.kpj (to.kpj matches folder name) + [* arbitrary/path/keyman.kpj] + * arbitrary/path/path.kpj (path.kpj matches folder name) + + For each project file, if found, verify that file.kmn is in the project: + * For a v2.0 project, file.kmn is in the project if it is in $SOURCEPATH + * For a v1.0 project, file.kmn must be listed explicitly. +} +function FindOwnerProjectForFile(const filename: string): string; +var + path: string; +begin + path := ExtractFilePath(filename); + +{ TODO: 18.0, see #10113 + // Check arbitrary/path/to/keyman.kpj + Result := path + C_ProjectStandardFilename; + if CheckOwnerProjectForFile(Result, filename) then + Exit; +} + + // Check arbitrary/path/to/to.kpj + Result := path + ExtractFileName(ExcludeTrailingPathDelimiter(path)) + '.kpj'; + if CheckOwnerProjectForFile(Result, filename) then + Exit; + + path := ExtractFilePath(ExcludeTrailingPathDelimiter(path)); + +{ TODO: 18.0, see #10113 + // Check arbitrary/path/keyman.kpj + Result := path + C_ProjectStandardFilename; + if CheckOwnerProjectForFile(Result, filename) then + Exit; +} + + // Check arbitrary/path/path.kpj + Result := path + ExtractFileName(ExcludeTrailingPathDelimiter(path)) + '.kpj'; + if CheckOwnerProjectForFile(Result, filename) then + Exit; + + Result := ''; +end; + +function CheckOwnerProjectForFile(const project, filename: string): Boolean; +var + p: TProject; +begin + if not FileExists(project) then + Exit(False); + + if SameFileName(project, filename) then + Exit(True); + + p := TProject.Create(ptUnknown, project, True); + try + Result := p.Files.IndexOfFileName(filename) >= 0; + finally + p.Free; + end; +end; + +end. diff --git a/developer/src/tike/main/Keyman.Developer.System.TikeCommandLine.pas b/developer/src/tike/main/Keyman.Developer.System.TikeCommandLine.pas new file mode 100644 index 00000000000..ab05833e4f5 --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.TikeCommandLine.pas @@ -0,0 +1,212 @@ +unit Keyman.Developer.System.TikeCommandLine; + +interface + +uses + System.Classes, + System.Generics.Collections, + + Keyman.Developer.System.TikeMultiProcess; + +type + TProcessCommandLine = (pclRun, pclExit); + +type + TTikeCommandLine = class + strict private + FProcesses: TTikeProcessList; + private + FStartupProjectPath: string; + FStartupFilenames: TArray; + function ProcessSubProcess: Boolean; + function GetFilenamesFromCommandLine(filenames: TStringList): Boolean; + public + constructor Create; + destructor Destroy; override; + + function Process: TProcessCommandLine; + property StartupProjectPath: string read FStartupProjectPath; + property StartupFilenames: TArray read FStartupFilenames; + end; + +function TikeCommandLine: TTikeCommandLine; + +implementation + +uses + System.SysUtils, + Vcl.Dialogs, + Winapi.Windows, + + ErrorControlledRegistry, + Keyman.Developer.System.LaunchProjects, + RegistryKeys, + utilexecute; + +{ TTikeCommandLine } + +constructor TTikeCommandLine.Create; +var + reg: TRegistryErrorControlled; +begin + inherited Create; + + FStartupProjectPath := ''; + + reg := TRegistryErrorControlled.Create; // I2890 + try + if reg.OpenKeyReadOnly(SRegKey_IDEOptions_CU) then + begin + if reg.ValueExists(SRegValue_ActiveProject) then + FStartupProjectPath := reg.ReadString(SRegValue_ActiveProject); + end; + finally + reg.Free; + end; + + if (FStartupProjectPath <> '') and not FileExists(FStartupProjectPath) then + begin + // The project has disappeared, so return to the welcome page + FStartupProjectPath := ''; + end; + + FProcesses := TikeMultiProcess.Enumerate; +end; + +destructor TTikeCommandLine.Destroy; +begin + FProcesses.Free; + inherited Destroy; +end; + +function TTikeCommandLine.GetFilenamesFromCommandLine(filenames: TStringList): Boolean; +var + filename, missingFilenames: string; + NoMoreSwitches: Boolean; + i: Integer; +begin + missingFilenames := ''; + NoMoreSwitches := False; + + for i := 1 to ParamCount do + begin + if not NoMoreSwitches and ParamStr(i).StartsWith('-') then + begin + // We will treat all `-x` and `--x` parameters as command-line switches, + // which provides forward compatibility for when we want to support + // additional switches. A single `--` parameter tells us that remaining + // parameters are filenames, even if they start with `-`. + if ParamStr(i) = '--' then + NoMoreSwitches := True; + Continue; + end; + + filename := ExpandFileName(ParamStr(i)); + if FileExists(filename) + then filenames.Add(filename) + else missingFilenames := missingFilenames + ' ' + filename + #13#10; + end; + + if missingFilenames <> '' then + begin + ShowMessage('The following file(s) could not be found:'#13#10+missingFilenames); + if filenames.Count = 0 then + // If we only had bogus filenames passed in then we should abort + Exit(False); + end; + + Result := True; +end; + +function TTikeCommandLine.ProcessSubProcess: Boolean; +var + i: Integer; +begin + if (ParamStr(1) <> '--sub-process') or (ParamCount < 2) then + begin + Exit(False); + end; + + // TODO(lowpri): Consider error handling + + FStartupProjectPath := ParamStr(2); + SetLength(FStartupFilenames, ParamCount - 2); + for i := 3 to ParamCount do + begin + FStartupFilenames[i-3] := ParamStr(i); + end; + + Result := True; +end; + +/// Reads filenames passed on command line, and determines if they should be +/// opened in an existing instance of Keyman Developer or in this instance, or +/// even in multiple new instances +function TTikeCommandLine.Process: TProcessCommandLine; +var + filenames: TStringList; + projects: TLaunchProjects; +begin + // If launched by LaunchNewInstance then we'll process that and continue + if ProcessSubProcess then + begin + Exit(pclRun); + end; + + filenames := TStringList.Create; + projects := TLaunchProjects.Create(True); + try + // Collect filenames passed in on command line + if not GetFilenamesFromCommandLine(filenames) then + Exit(pclExit); + + if filenames.Count = 0 then + begin + // No filenames were passed on the command line + if FProcesses.Count > 0 then + begin + // Because this is a new instance and there are already running instances, + // we'll start Keyman Developer without opening a project, assuming that + // the last opened project has already been opened. + FStartupProjectPath := ''; + end; + Exit(pclRun); + end; + + // Sort filenames into project groupings + + projects.GroupFilenamesIntoProjects(filenames); + + // Launch new processes or load files into existing processes + + if not projects.LaunchAll(FProcesses) + then Result := pclExit + else Result := pclRun; + + if Assigned(projects.StartupProject) then + begin + // LaunchAll will leave one startup project for this current process + // instance to load + FStartupFilenames := projects.StartupProject.Filenames.ToStringArray; + FStartupProjectPath := projects.StartupProject.ProjectFilename; + end; + finally + projects.Free; + filenames.Free; + end; +end; + +var + FInstance: TTikeCommandLine = nil; + +function TikeCommandLine: TTikeCommandLine; +begin + if not Assigned(FInstance) then + FInstance := TTikeCommandLine.Create; + Result := FInstance; +end; + +initialization +finalization + FreeAndNil(FInstance); +end. diff --git a/developer/src/tike/main/Keyman.Developer.System.TikeMultiProcess.pas b/developer/src/tike/main/Keyman.Developer.System.TikeMultiProcess.pas new file mode 100644 index 00000000000..2cd35d8204b --- /dev/null +++ b/developer/src/tike/main/Keyman.Developer.System.TikeMultiProcess.pas @@ -0,0 +1,165 @@ +unit Keyman.Developer.System.TikeMultiProcess; + +interface + +uses + System.Generics.Collections, + Keyman.Developer.System.MultiProcess; + +type + TTikeProcess = class + WindowHandle: THandle; + ThreadID: Cardinal; + ProjectFilename: string; + SourcePath: string; + function OwnsProject(const filename: string): Boolean; + function OpenFile(const filename: string): Boolean; + function FocusProcess: Boolean; + end; + + TTikeProcessList = TObjectList; + + TTikeMultiProcess = class + public + constructor Create; + destructor Destroy; override; + procedure CloseProject; + procedure OpenProject(const filename, sourcepath: string); + function Enumerate: TTikeProcessList; + end; + +function TikeMultiProcess: TTikeMultiProcess; + +implementation + +uses + System.JSON, + System.SysUtils, + Winapi.Messages, + Winapi.Windows, + + Keyman.System.CopyDataHelper, + RegistryKeys, + UfrmMain, + utilfiletypes; + +const + JSON_Filename = 'Filename'; + JSON_SourcePath = 'SourcePath'; + +{ TTikeMultiProcess } + +procedure TTikeMultiProcess.CloseProject; +begin + // Marks this instance as available to open another project + MultiProcessCoordinator.SetProcessIdentifier('*'); +end; + +constructor TTikeMultiProcess.Create; +begin + inherited Create; + CreateMultiProcessCoordinator(TfrmKeymanDeveloper.ClassName, SRegKey_IDEActiveProjects_CU); + CloseProject; +end; + +destructor TTikeMultiProcess.Destroy; +begin + inherited Destroy; +end; + +function TTikeMultiProcess.Enumerate: TTikeProcessList; +var + tp: TTikeProcess; + p: TMultiProcessInstance; + j: TJSONValue; + jo: TJSONObject; +begin + Result := TObjectList.Create; + MultiProcessCoordinator.Enumerate; + for p in MultiProcessCoordinator.Processes do + begin + j := TJSONObject.ParseJSONValue(p.Identifier); + try + if (j = nil) or not (j is TJSONObject) then + begin + Continue; + end; + + jo := j as TJSONObject; + if not (jo.Values[JSON_Filename] is TJSONString) or + not (jo.Values[JSON_SourcePath] is TJSONString) then + begin + Continue; + end; + + tp := TTikeProcess.Create; + tp.ProjectFilename := jo.Values[JSON_Filename].Value; + tp.SourcePath := jo.Values[JSON_SourcePath].Value; + tp.WindowHandle := p.Handle; + tp.ThreadID := p.ThreadId; + Result.Add(tp); + finally + j.Free; + end; + end; +end; + +procedure TTikeMultiProcess.OpenProject(const filename, sourcepath: string); +var + j: TJSONObject; +begin + j := TJSONObject.Create; + try + j.AddPair(JSON_Filename, filename); + j.AddPair(JSON_SourcePath, sourcepath); + MultiProcessCoordinator.SetProcessIdentifier(j.ToJSON); + finally + j.Free; + end; +end; + +{ TTikeProcess } + +function TTikeProcess.FocusProcess: Boolean; +begin + if not AttachThreadInput(GetCurrentThreadId, ThreadID, True) then + Exit(False); + + Result := BringWindowToTop(WindowHandle); + SetForegroundWindow(WindowHandle); + + AttachThreadInput(GetCurrentThreadId, ThreadID, False); +end; + +function TTikeProcess.OpenFile(const filename: string): Boolean; +begin + if not TCopyDataHelper.SendData(0, WindowHandle, TCopyDataCommand.CD_OPENFILE, filename) then + begin + // The target app did not receive the data + Exit(False); + end; + + Exit(FocusProcess); +end; + +function TTikeProcess.OwnsProject(const filename: string): Boolean; +begin + Result := SameFileName(filename, ProjectFilename); +end; + +var + FInstance: TTikeMultiProcess = nil; + +function TikeMultiProcess: TTikeMultiProcess; +begin + if not Assigned(FInstance) then + begin + FInstance := TTikeMultiProcess.Create; + end; + Result := FInstance; +end; + +initialization +finalization + FreeAndNil(FInstance); +end. diff --git a/developer/src/tike/main/Keyman.System.CopyDataHelper.pas b/developer/src/tike/main/Keyman.System.CopyDataHelper.pas new file mode 100644 index 00000000000..b30b9f18994 --- /dev/null +++ b/developer/src/tike/main/Keyman.System.CopyDataHelper.pas @@ -0,0 +1,61 @@ +unit Keyman.System.CopyDataHelper; + +interface + +uses + Winapi.Messages, + Winapi.Windows; + +type + TCopyDataCommand = (CD_OPENFILE = 0); + +type + TCopyDataHelper = class sealed + private + // This rudimentary validation stops other apps accidentally + // sending us WM_COPYDATA messages with bogus content. + const CopyDataGuid = '{2F2BB073-CAD3-4AAA-8BEF-B8DB5F06D329}'; + public + class function SendData(SenderHandle, ReceiverHandle: THandle; id: TCopyDataCommand; const data: string): Boolean; + class function ReceiveData(var Message: TWMCopyData; var id: TCopyDataCommand; var data: string): Boolean; + end; + +implementation + +uses + System.SysUtils; + +class function TCopyDataHelper.SendData(SenderHandle, ReceiverHandle: THandle; id: TCopyDataCommand; const data: string): Boolean; +var + cds: TCopyDataStruct; + dwResult: DWord; + buffer: string; +begin + buffer := CopyDataGuid + data; + cds.dwData := DWord(id); + cds.cbData := (buffer.Length+1) * sizeof(Char); + cds.lpData := PChar(buffer); + + Result := SendMessageTimeout( + ReceiverHandle, WM_COPYDATA, SenderHandle, NativeInt(@cds), + SMTO_NORMAL, 1000, @dwResult) <> 0; +end; + +class function TCopyDataHelper.ReceiveData(var Message: TWMCopyData; var id: TCopyDataCommand; var data: string): Boolean; +var + buffer: PChar; + bufferData: string; +begin + Message.Result := ERROR_INVALID_DATA; + buffer := PChar(Message.CopyDataStruct.lpData); + bufferData := Copy(buffer, 1, Message.CopyDataStruct.cbData div sizeof(Char) - 1); + if not bufferData.StartsWith(CopyDataGuid) then + Exit(False); + + id := TCopyDataCommand(Message.CopyDataStruct.dwData); + data := bufferData.Substring(CopyDataGuid.Length); + Message.Result := ERROR_SUCCESS; + Result := True; +end; + +end. diff --git a/developer/src/tike/main/UfrmMain.dfm b/developer/src/tike/main/UfrmMain.dfm index 311c1fd67d5..6b331d1e960 100644 --- a/developer/src/tike/main/UfrmMain.dfm +++ b/developer/src/tike/main/UfrmMain.dfm @@ -2935,9 +2935,6 @@ inherited frmKeymanDeveloper: TfrmKeymanDeveloper object OpenProject1: TMenuItem Action = modActionsMain.actProjectOpen end - object OpenProjectFolder1: TMenuItem - Action = modActionsMain.actProjectOpenFolder - end object CloseProject1: TMenuItem Action = modActionsMain.actProjectClose end diff --git a/developer/src/tike/main/UfrmMain.pas b/developer/src/tike/main/UfrmMain.pas index b8b8d526ab9..53fd7e378e7 100644 --- a/developer/src/tike/main/UfrmMain.pas +++ b/developer/src/tike/main/UfrmMain.pas @@ -314,7 +314,6 @@ TfrmKeymanDeveloper = class(TTikeForm, IUnicodeDataUIManager, IDragDrop) Stopserver1: TMenuItem; ToolButton13: TToolButton; ToolButton16: TToolButton; - OpenProjectFolder1: TMenuItem; procedure FormCreate(Sender: TObject); procedure FormShow(Sender: TObject); procedure mnuFileClick(Sender: TObject); @@ -355,10 +354,15 @@ TfrmKeymanDeveloper = class(TTikeForm, IUnicodeDataUIManager, IDragDrop) FCanClose: Boolean; FHasDoneCloseCleanup: Boolean; + FFilesToOpen: TStringList; + //procedure ChildWindowsChange(Sender: TObject); procedure WMUserFormShown(var Message: TMessage); message WM_USER_FORMSHOWN; procedure WMUserInputLangChange(var Message: TMessage); message WM_USER_INPUTLANGCHANGE; + procedure WMCopyData(var Message: TWMCopyData); message WM_COPYDATA; + procedure WMUserOpenFiles(var Message: TMessage); message WM_USER_OpenFiles; + procedure UpdateFileMRU; procedure ProjectMRUChange(Sender: TObject); @@ -383,8 +387,8 @@ TfrmKeymanDeveloper = class(TTikeForm, IUnicodeDataUIManager, IDragDrop) { IDropTarget } - procedure Drop(const FileNames: array of string); - function DropAllowed(const FileNames: array of string): Boolean; + procedure Drop(const FileNames: TArray); + function DropAllowed(const FileNames: TArray): Boolean; procedure InitDock; procedure LoadDockLayout; procedure SaveDockLayout; @@ -440,7 +444,9 @@ TfrmKeymanDeveloper = class(TTikeForm, IUnicodeDataUIManager, IDragDrop) procedure RefreshOptions; function OpenEditor(FFileName: string; frmClass: TfrmTikeEditorClass): TfrmTikeEditor; + procedure OpenProject(const filename: string); function OpenFile(FFileName: string; FCloseNewFile: Boolean): TfrmTikeChild; + procedure OpenFilesInProject(FFileNames: TArray); procedure HelpTopic(s: string); overload; procedure HelpTopic(Sender: TTIKEForm); overload; @@ -472,15 +478,19 @@ implementation HTMLHelpViewer, KLog, KeymanVersion, + Keyman.System.CopyDataHelper, Keyman.System.KeymanSentryClient, Keyman.Developer.UI.TikeOnlineUpdateCheck, GlobalProxySettings, + Keyman.Developer.System.LaunchProjects, Keyman.Developer.System.Project.ProjectFile, Keyman.Developer.System.Project.ProjectFileType, Keyman.Developer.System.Project.WelcomeRenderer, Keyman.Developer.System.Project.ProjectLoader, Keyman.Developer.System.Project.ProjectLog, Keyman.Developer.System.Project.XmlLdmlProjectFile, + Keyman.Developer.System.TikeCommandLine, + Keyman.Developer.System.TikeMultiProcess, Keyman.Developer.UI.Project.ProjectFileUI, Keyman.Developer.UI.Project.ProjectUI, Keyman.Developer.UI.UfrmLdmlKeyboardEditor, @@ -526,11 +536,11 @@ function CallWndProc_InputLangChange(nCode: Integer; wParam: WPARAM; lParam: LPA procedure TfrmKeymanDeveloper.FormCreate(Sender: TObject); -var - FActiveProject: string; begin inherited; + FFilesToOpen := TStringList.Create; + if not ForceDirectories(FKeymanDeveloperOptions.DefaultProjectPath) then begin // Fall back to Documents folder if we cannot create the default project path @@ -564,8 +574,6 @@ procedure TfrmKeymanDeveloper.FormCreate(Sender: TObject); Application.HelpFile := GetHelpURL; // I4677 // I4841 mHHelp := TWebHookHelpSystem.Create(Application.HelpFile); // I4677 // I4841 - FActiveProject := ''; - with TRegistryErrorControlled.Create do // I2890 try RootKey := HKEY_CURRENT_USER; @@ -573,7 +581,6 @@ procedure TfrmKeymanDeveloper.FormCreate(Sender: TObject); begin if ValueExists(SRegValue_IDEOptToolbarVisible) and (ReadString(SRegValue_IDEOptToolbarVisible) = '0') then barTools.Visible := False; - if ValueExists(SRegValue_ActiveProject) then FActiveProject := ReadString(SRegValue_ActiveProject); end; finally Free; @@ -582,14 +589,10 @@ procedure TfrmKeymanDeveloper.FormCreate(Sender: TObject); RemoveOldestTikeEditFonts(False); RemoveOldestTikeTestFonts(False); - if (FActiveProject <> '') and not FileExists(FActiveProject) then - // TODO: we need to support folder-based projects here - FActiveProject := ''; - - if FActiveProject <> '' then + if TikeCommandLine.StartupProjectPath <> '' then begin try - LoadGlobalProjectUI(ptUnknown, FActiveProject); + LoadGlobalProjectUI(ptUnknown, TikeCommandLine.StartupProjectPath); except on E:EProjectLoader do begin @@ -599,6 +602,8 @@ procedure TfrmKeymanDeveloper.FormCreate(Sender: TObject); end; end; + UpdateCaption; + InitDock; frmMessages := TfrmMessages.Create(Self); @@ -750,6 +755,8 @@ procedure TfrmKeymanDeveloper.FormDestroy(Sender: TObject); FreeUnicodeData; FreeAndNil(AppStorage); + + FreeAndNil(FFilesToOpen); end; procedure TfrmKeymanDeveloper.FormShow(Sender: TObject); @@ -771,19 +778,47 @@ procedure TfrmKeymanDeveloper.WMActivate(var Message: TWMActivate); end; end; +procedure TfrmKeymanDeveloper.WMCopyData(var Message: TWMCopyData); +var + id: TCopyDataCommand; + filename: string; +begin + if not TCopyDataHelper.ReceiveData(Message, id, filename) then + begin + // Invalid data, we'll ignore the message + Message.Result := ERROR_INVALID_FUNCTION; + Exit; + end; + + if id <> TCopyDataCommand.CD_OPENFILE then + begin + Message.Result := ERROR_INVALID_FUNCTION; + end + else if not FileExists(filename) then + begin + Message.Result := ERROR_FILE_NOT_FOUND; + end + else + begin + FFilesToOpen.Add(filename); + PostMessage(Handle, WM_USER_OpenFiles, 0, 0); + Message.Result := 0; + end; +end; + procedure TfrmKeymanDeveloper.WMUserFormShown(var Message: TMessage); var - i: Integer; + filename: string; begin - if ParamCount = 0 then + if Length(TikeCommandLine.StartupFilenames) = 0 then begin ShowProject; end else begin - for i := 1 to ParamCount do - if FileExists(ParamStr(i)) then - OpenFile(ParamStr(i), False); + for filename in TikeCommandLine.StartupFilenames do + if FileExists(filename) then + OpenFile(filename, False); end; if True then //FKeymanDeveloperOptions.AutoCheckForUpdates then @@ -801,6 +836,17 @@ procedure TfrmKeymanDeveloper.WMUserInputLangChange(var Message: TMessage); PostMessage(FChildWindows[i].Handle, WM_USER_INPUTLANGCHANGE, Message.wParam, Message.LParam); end; +procedure TfrmKeymanDeveloper.WMUserOpenFiles(var Message: TMessage); +var + filename: string; +begin + for filename in FFilesToOpen do + begin + OpenFile(filename, False); + end; + FFilesToOpen.Clear; +end; + const SPI_GETKEYBOARDCUES = $100A; var @@ -1164,6 +1210,57 @@ procedure TfrmKeymanDeveloper.pagesCloseTab(Sender: TObject; Index: Integer); PostMessage(TfrmTikeChild(pages.Pages[Index].Tag).Handle, WM_CLOSE, 0, 0); end; +procedure TfrmKeymanDeveloper.OpenFilesInProject(FFileNames: TArray); +var + filename: string; + filenames: TStringList; + projects: TLaunchProjects; + p: TLaunchProject; + FProcesses: TTikeProcessList; +begin + if Length(FFileNames) = 0 then + Exit; + + // Uses the TTikeCommandLine pattern to open the resulting file either locally + // or in a remote process + FProcesses := TikeMultiProcess.Enumerate; + projects := TLaunchProjects.Create(False); + filenames := TStringList.Create; + try + filenames.AddStrings(FFileNames); + FFileNames := []; + + // Sort filenames into project groupings + projects.GroupFilenamesIntoProjects(filenames); + + // Look for the current instance's project in the list of projects, and + // capture its list of filenames separately + if IsGlobalProjectUIReady then + begin + for p in projects do + begin + if SameFileName(p.ProjectFilename, FGlobalProject.FileName) then + begin + FFileNames := p.Filenames.ToStringArray; + projects.Remove(p); + Break; + end; + end; + end; + + // Launch new processes or load files into existing processes + projects.LaunchAll(FProcesses); + finally + filenames.Free; + projects.Free; + FProcesses.Free; + end; + + // Finally, open the remaining files which belong to this instance's project + for filename in FFileNames do + OpenFile(filename, True); +end; + function TfrmKeymanDeveloper.OpenFile(FFileName: string; FCloseNewFile: Boolean): TfrmTikeChild; function FileHasModelTsExt(Filename: string): Boolean; begin @@ -1173,21 +1270,28 @@ function TfrmKeymanDeveloper.OpenFile(FFileName: string; FCloseNewFile: Boolean) var ext: string; begin - if not IsGlobalProjectUIReady then - begin - // TODO: we need to open the parent folder as a project, if possible - // This can happen if we get a file opened via Explorer. - ShowMessage('TODO -- open parent folder as project'); - Exit(nil); - end; + Result := nil; - if DirectoryExists(FFileName) then + if not IsProjectFile(FFileName) then begin - // This is an attempt to open a project folder? - // TODO + if not IsGlobalProjectUIReady then + begin + // We need to create a temporary project to host this file + // This can happen if we get a file opened via Explorer without a + // corresonding project file + CreateTempGlobalProjectUI(ptUnknown); + UpdateCaption; + end; + + if GetGlobalProjectUI.IsTemporary then + begin + if FGlobalProject.Files.IndexOfFileName(FFileName) < 0 then + begin + CreateProjectFile(FGlobalProject, FFileName, nil); + end; + end; end; - Result := nil; Screen.Cursor := crHourglass; try try @@ -1195,6 +1299,12 @@ function TfrmKeymanDeveloper.OpenFile(FFileName: string; FCloseNewFile: Boolean) if ext = Ext_ProjectSource then begin + if SameFileName(GetGlobalProjectUI.FileName, FFileName) then + begin + ShowProject; + Exit; + end; + if BeforeOpenProject then modActionsMain.OpenProject(FFileName); end @@ -1362,23 +1472,19 @@ procedure TfrmKeymanDeveloper.UpdateFileMRU; procedure TfrmKeymanDeveloper.mnuFileRecentFileClick(Sender: TObject); begin with Sender as TMenuItem do - OpenFile(Hint, True); + OpenFilesInProject([Hint]); end; {------------------------------------------------------------------------------- - Drag and Drop functionality - -------------------------------------------------------------------------------} -procedure TfrmKeymanDeveloper.Drop(const FileNames: array of string); -var - i: Integer; +procedure TfrmKeymanDeveloper.Drop(const FileNames: TArray); begin - for i := Low(Filenames) to High(Filenames) do - OpenFile(Filenames[i], False); + OpenFilesInProject(FileNames); end; -function TfrmKeymanDeveloper.DropAllowed( - const FileNames: array of string): Boolean; +function TfrmKeymanDeveloper.DropAllowed(const FileNames: TArray): Boolean; begin Result := True; end; @@ -1458,11 +1564,22 @@ procedure TfrmKeymanDeveloper.mnuProjectClick(Sender: TObject); end; procedure TfrmKeymanDeveloper.mnuProjectRecentFileClick(Sender: TObject); +var + filename: string; begin - with Sender as TMenuItem do + filename := (Sender as TMenuItem).Hint; + OpenProject(filename); +end; + +procedure TfrmKeymanDeveloper.OpenProject(const filename: string); +begin + if IsGlobalProjectUIReady then + begin + OpenFilesInProject([filename]); + end + else if BeforeOpenProject then begin - if BeforeOpenProject then - modActionsMain.OpenProject(Hint); + modActionsMain.OpenProject(filename); end; end; @@ -1546,11 +1663,16 @@ procedure TfrmKeymanDeveloper.UDUI_UpdateStatus(const Msg: WideString; Pos, end; procedure TfrmKeymanDeveloper.UpdateCaption; +const + C_StandardCaption = 'Keyman Developer'; + C_TemporaryProject = 'Temporary Project'; begin if not IsGlobalProjectUIReady then - Caption := 'Keyman Developer' + Caption := C_StandardCaption + else if GetGlobalProjectUI.IsTemporary then + Caption := C_TemporaryProject+' - '+C_StandardCaption else - Caption := ChangeFileExt(ExtractFileName(FGlobalProject.FileName), '') + ' - Keyman Developer'; + Caption := ChangeFileExt(ExtractFileName(FGlobalProject.FileName), '')+' - '+C_StandardCaption; end; procedure TfrmKeymanDeveloper.UpdateChildCaption(Window: TfrmTikeChild); diff --git a/developer/src/tike/project/Keyman.Developer.System.Project.Project.pas b/developer/src/tike/project/Keyman.Developer.System.Project.Project.pas index 539d071672f..57d36f711bc 100644 --- a/developer/src/tike/project/Keyman.Developer.System.Project.Project.pas +++ b/developer/src/tike/project/Keyman.Developer.System.Project.Project.pas @@ -4,6 +4,8 @@ interface uses System.Classes, + Winapi.Messages, + Keyman.Developer.System.Project.ProjectFile; var @@ -11,6 +13,79 @@ interface FGlobalProjectRefresh: TNotifyEvent = nil; FGlobalProjectRefreshCaption: TNotifyEvent = nil; +const + WM_USER_ProjectUpdateDisplayState = WM_USER; + +function GlobalProjectStateWndHandle: THandle; + implementation +uses + System.SysUtils, + Winapi.Windows; + +type + TGlobalProjectStateWnd = class + private + procedure WndProc(var Message: TMessage); + constructor Create; + destructor Destroy; override; + end; + +var + FGlobalProjectStateWnd: TGlobalProjectStateWnd = nil; + + // Make this a global to prevent potential race + // condition causing an access violation. If it + // is an invalid window handle or 0 at destruction time, + // it's no big deal... + FGlobalProjectStateWndHandle: THandle = 0; + +{ TGlobalProjectStateWnd } + +constructor TGlobalProjectStateWnd.Create; +begin + inherited Create; + FGlobalProjectStateWndHandle := AllocateHWnd(WndProc); +end; + +destructor TGlobalProjectStateWnd.Destroy; +var + h: THandle; +begin + h := FGlobalProjectStateWndHandle; + FGlobalProjectStateWndHandle := 0; + DeallocateHWnd(h); + inherited Destroy; +end; + +procedure TGlobalProjectStateWnd.WndProc(var Message: TMessage); +var + PPath, PDisplayState: PChar; +begin + if Message.Msg = WM_USER_ProjectUpdateDisplayState then + begin + PPath := PChar(Message.WParam); + PDisplayState := PChar(Message.LParam); + if Assigned(FGlobalProject) and (FGlobalProject.FileName = PPath) then + begin + FGlobalProject.DisplayState := PDisplayState; + FGlobalProject.SaveUser; + end; + StrDispose(PDisplayState); + StrDispose(PPath); + end; + DefWindowProc(FGlobalProjectStateWndHandle, Message.Msg, Message.WParam, Message.LParam); +end; + +function GlobalProjectStateWndHandle: THandle; +begin + Result := FGlobalProjectStateWndHandle; +end; + +initialization + FGlobalProjectStateWnd := TGlobalProjectStateWnd.Create; +finalization + // Deletes temporary session-local project + FGlobalProjectStateWnd.Free; end. diff --git a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFile.pas b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFile.pas index e5ddbf9e316..4e2fad5ec6c 100644 --- a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFile.pas +++ b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFile.pas @@ -134,8 +134,8 @@ TProjectOptions = class ProjectType: ptKeyboard; Version: pv10 ), ( // 2.0 - BuildPath: '$PROJECTPATH/build'; - SourcePath: '$PROJECTPATH/source'; + BuildPath: '$PROJECTPATH\build'; + SourcePath: '$PROJECTPATH\source'; CompilerWarningsAsErrors: False; WarnDeprecatedCode: True; CheckFilenameConventions: False; @@ -144,6 +144,11 @@ TProjectOptions = class Version: pv20 )); +{ TODO: this will be enabled in 18.0; see #10113 +const + C_ProjectStandardFilename = 'keyman.kpj'; +} + type { Forward declarations } @@ -195,7 +200,7 @@ TProject = class public procedure Log(AState: TProjectLogState; Filename, Msg: string; MsgCode, line: Integer); virtual; - constructor Create(AProjectType: TProjectType; AFileName: string); virtual; + constructor Create(AProjectType: TProjectType; AFileName: string; ALoad: Boolean); virtual; destructor Destroy; override; procedure Refresh; @@ -353,10 +358,6 @@ TProjectFileStates = class(TObjectList) procedure ProjectFileDestroying(ProjectFile: TProjectFile); end; -const - WM_USER_ProjectUpdateDisplayState = WM_USER; - -function GlobalProjectStateWndHandle: THandle; function ProjectTypeFromString(s: string): TProjectType; function ProjectTypeToString(pt: TProjectType): string; @@ -581,7 +582,10 @@ function TProjectFile.GetOwnerProject: TProject; if Assigned(FParent) then Result := FParent.OwnerProject; if Result = nil then + begin + // TODO: RAISE ERROR Result := FGlobalProject; + end; end; end; @@ -621,8 +625,8 @@ function TProjectFile.IsSourceFile: Boolean; Exit(True); // Only return true if the file is directly in the ProjectOptions.SourcePath folder - SourcePath := ReplaceStr(IncludeTrailingPathDelimiter(FProject.ResolveProjectPath(FProject.Options.SourcePath)), '/', '\'); - FilePath := ReplaceStr(ExtractFilePath(FFileName), '/', '\'); + SourcePath := DosSlashes(FProject.ResolveProjectPath(FProject.Options.SourcePath)); + FilePath := DosSlashes(ExtractFilePath(FFileName)); Result := SameFileName(SourcePath, FilePath); end; @@ -662,7 +666,7 @@ procedure TProjectFile.Save(node: IXMLNode); // I4698 begin node.AddChild('ID').NodeValue := FID; node.AddChild('Filename').NodeValue := ExtractFileName(FFileName); - node.AddChild('Filepath').NodeValue := ExtractRelativePath(FProject.FileName, FFileName); + node.AddChild('Filepath').NodeValue := ExtractRelativePath(FProject.FileName, DosSlashes(FFileName)); node.AddChild('FileVersion').NodeValue := FFileVersion; // I4701 // Note: FileType is only ever written in Delphi code; it is used by xsl @@ -745,7 +749,7 @@ procedure TProject.ChildRefresh(Sender: TObject); end; end; -constructor TProject.Create(AProjectType: TProjectType; AFileName: string); +constructor TProject.Create(AProjectType: TProjectType; AFileName: string; ALoad: Boolean); var i: Integer; begin @@ -773,7 +777,7 @@ constructor TProject.Create(AProjectType: TProjectType; AFileName: string); FMustSave := False; - if not Load then // I4703 + if ALoad and not Load then // I4703 begin raise EProjectLoader.Create('Unable to load project '+FFileName); end; @@ -845,9 +849,6 @@ function TProject.Load: Boolean; then Result := LoadFromXML(FileName) else Result := ImportFromIni(FileName); end - else if DirectoryExists(ExtractFilePath(FileName)) then - // This will fall back to a 2.0 folder load - Result := LoadFromXML(FileName) else begin Result := False; @@ -978,18 +979,21 @@ function TProject.IsDefaultProject(Version: TProjectVersion): Boolean; /// function TProject.PopulateFiles: Boolean; var - ProjectPath: string; + SourcePath, ProjectPath: string; begin if FOptions.Version <> pv20 then raise EProjectLoader.Create('PopulateFiles can only be called on a v2.0 project'); FFiles.Clear; - ProjectPath := ExtractFilePath(FileName); + ProjectPath := ExpandFileName(ExtractFilePath(FileName)); if not DirectoryExists(ProjectPath) then Exit(False); PopulateFolder(ProjectPath); + SourcePath := ResolveProjectPath(FOptions.SourcePath); + if not SameFileName(ProjectPath, SourcePath) and DirectoryExists(SourcePath) then + PopulateFolder(SourcePath); Result := True; end; @@ -999,7 +1003,7 @@ procedure TProject.PopulateFolder(const path: string); ff: string; f: TSearchRec; begin - if FindFirst(path + '*', faDirectory, f) = 0 then + if FindFirst(path + '*', 0, f) = 0 then begin repeat ff := path + f.Name; @@ -1009,12 +1013,6 @@ procedure TProject.PopulateFolder(const path: string); Continue; end; - if (f.Attr and faDirectory) = faDirectory then - begin - PopulateFolder(ff + '\'); - Continue; - end; - CreateProjectFile(Self, ff, nil); until FindNext(f) <> 0; System.SysUtils.FindClose(f); @@ -1231,7 +1229,7 @@ function TProject.GetUserFileName: string; function TProject.ResolveProjectPath(APath: string): string; begin - Result := ReplaceText(APath, '$PROJECTPATH', ExtractFileDir(ExpandFileName(FFileName))); + Result := IncludeTrailingPathDelimiter(ReplaceText(APath, '$PROJECTPATH', ExtractFileDir(ExpandFileName(FFileName)))); end; function TProject.GetTargetFilename10(ATargetFile, ASourceFile, AVersion: string): string; // I4688 @@ -1256,7 +1254,6 @@ function TProject.GetTargetFilename20(ATargetFile, ASourceFile, AVersion: string Exit(ExtractFilePath(ExpandFileName(ASourceFile)) + ExtractFileName(ATargetFile)); end; - Result := IncludeTrailingPathDelimiter(Result); Result := ResolveProjectPath(Result); Result := Result + ExtractFileName(ATargetFile); end; @@ -1403,65 +1400,6 @@ function TProjectOptions.EqualsRecord(source: TProjectOptionsRecord): Boolean; (Self.Version = Source.Version); end; -type - TGlobalProjectStateWnd = class - private - procedure WndProc(var Message: TMessage); - constructor Create; - destructor Destroy; override; - end; - -var - FGlobalProjectStateWnd: TGlobalProjectStateWnd = nil; - - // Make this a global to prevent potential race - // condition causing an access violation. If it - // is an invalid window handle or 0 at destruction time, - // it's no big deal... - FGlobalProjectStateWndHandle: THandle = 0; - -{ TGlobalProjectStateWnd } - -constructor TGlobalProjectStateWnd.Create; -begin - inherited Create; - FGlobalProjectStateWndHandle := AllocateHWnd(WndProc); -end; - -destructor TGlobalProjectStateWnd.Destroy; -var - h: THandle; -begin - h := FGlobalProjectStateWndHandle; - FGlobalProjectStateWndHandle := 0; - DeallocateHWnd(h); - inherited Destroy; -end; - -procedure TGlobalProjectStateWnd.WndProc(var Message: TMessage); -var - PPath, PDisplayState: PChar; -begin - if Message.Msg = WM_USER_ProjectUpdateDisplayState then - begin - PPath := PChar(Message.WParam); - PDisplayState := PChar(Message.LParam); - if Assigned(FGlobalProject) and (FGlobalProject.FileName = PPath) then - begin - FGlobalProject.DisplayState := PDisplayState; - FGlobalProject.SaveUser; - end; - StrDispose(PDisplayState); - StrDispose(PPath); - end; - DefWindowProc(FGlobalProjectStateWndHandle, Message.Msg, Message.WParam, Message.LParam); -end; - -function GlobalProjectStateWndHandle: THandle; -begin - Result := FGlobalProjectStateWndHandle; -end; - function ProjectTypeFromString(s: string): TProjectType; begin if SameText(s, 'keyboard') then Result := ptKeyboard @@ -1494,9 +1432,4 @@ function ProjectVersionToString(pv: TProjectVersion): string; end; end; -initialization - FGlobalProjectStateWnd := TGlobalProjectStateWnd.Create; -finalization - // Deletes temporary session-local project - FGlobalProjectStateWnd.Free; end. diff --git a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFiles.pas b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFiles.pas index 2f79dccbe38..112fcee68f7 100644 --- a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFiles.pas +++ b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectFiles.pas @@ -70,5 +70,7 @@ initialization RegisterProjectFileType('.html', TOpenableProjectFile); // I1769 RegisterProjectFileType('.xml', TOpenableProjectFile); // I1769 RegisterProjectFileType('.js', TOpenableProjectFile); + RegisterProjectFileType('.kpj', TOpenableProjectFile); + RegisterProjectFileType('.user', TOpenableProjectFile); end. diff --git a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectLoader.pas b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectLoader.pas index c68339ab861..b31240631bf 100644 --- a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectLoader.pas +++ b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectLoader.pas @@ -48,7 +48,6 @@ TProjectLoader = class FFileName: string; FProject: TProject; procedure LoadUser; - procedure LoadDefaultProjectFromFolder; procedure LoadProjectFromFile; // I4698 public constructor Create(AProject: TProject; AFileName: string); @@ -66,6 +65,7 @@ implementation Keyman.Developer.System.Project.ProjectFiles, Keyman.Developer.System.Project.ProjectFileType, + utildir, utilfiletypes; { TProjectLoader } @@ -79,21 +79,9 @@ constructor TProjectLoader.Create(AProject: TProject; AFileName: string); procedure TProjectLoader.Execute; // I4698 begin - if FileExists(FFileName) or (FFileName = '') - then LoadProjectFromFile - else LoadDefaultProjectFromFolder; + LoadProjectFromFile; end; -procedure TProjectLoader.LoadDefaultProjectFromFolder; -begin - FProject.Options.Assign(DefaultProjectOptions[pv20]); - if not FProject.PopulateFiles then - // TODO: This seems somewhat arbitrary and troublesome. Better to load the - // folder and give warnings about file layout - raise EProjectLoader.Create('Not a Keyman Developer project folder'); -end; - - procedure TProjectLoader.LoadProjectFromFile; var n, i: Integer; @@ -131,10 +119,10 @@ procedure TProjectLoader.LoadProjectFromFile; FProject.Options.Assign(DefaultProjectOptions[FProject.Options.Version]); if not VarIsNull(node.ChildValues['BuildPath']) then - FProject.Options.BuildPath := VarToStr(node.ChildValues['BuildPath']); + FProject.Options.BuildPath := DosSlashes(VarToStr(node.ChildValues['BuildPath'])); if not VarIsNull(node.ChildValues['SourcePath']) then - FProject.Options.SourcePath := VarToStr(node.ChildValues['SourcePath']); + FProject.Options.SourcePath := DosSlashes(VarToStr(node.ChildValues['SourcePath'])); if not VarIsNull(node.ChildValues['CompilerWarningsAsErrors']) then FProject.Options.CompilerWarningsAsErrors := node.ChildValues['CompilerWarningsAsErrors']; diff --git a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectSaver.pas b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectSaver.pas index 20b85e3cfdc..40d8b301915 100644 --- a/developer/src/tike/project/Keyman.Developer.System.Project.ProjectSaver.pas +++ b/developer/src/tike/project/Keyman.Developer.System.Project.ProjectSaver.pas @@ -86,13 +86,6 @@ procedure TProjectSaver.Execute; // I4698 filenode, node, root: IXMLNode; defopts: TProjectOptionsRecord; begin - if FProject.IsDefaultProject(pv20) and (FFileName <> '') then - begin - if FileExists(FFileName) then - System.SysUtils.DeleteFile(FFileName); - Exit; - end; - defopts := DefaultProjectOptions[FProject.Options.Version]; doc := NewXMLDocument(); diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFileUI.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFileUI.pas index f850f9a9dad..ee5c4cb1260 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFileUI.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFileUI.pas @@ -39,6 +39,7 @@ interface TProjectUI = class(TProject) private FRenderFileName: TTempFile; + FIsTemporary: Boolean; function GetRenderFileName: string; procedure Refresh; @@ -47,7 +48,7 @@ TProjectUI = class(TProject) procedure DoRefreshCaption; override; public - constructor Create(AProjectType: TProjectType; AFileName: string); override; + constructor Create(AProjectType: TProjectType; AFileName: string; ALoad: Boolean); override; destructor Destroy; override; procedure Log(AState: TProjectLogState; Filename, Msg: string; MsgCode, line: Integer); override; // I4706 @@ -61,6 +62,8 @@ TProjectUI = class(TProject) function Load: Boolean; override; // I4694 property RenderFileName: string read GetRenderFileName; // I4181 + + property IsTemporary: Boolean read FIsTemporary write FIsTemporary; end; TProjectFileUI = class @@ -129,7 +132,7 @@ procedure TProjectUI.DoRefreshCaption; { TProjectUI } -constructor TProjectUI.Create(AProjectType: TProjectType; AFileName: string); +constructor TProjectUI.Create(AProjectType: TProjectType; AFileName: string; ALoad: Boolean); begin inherited; FRenderFileName := TTempFileManager.Get('.html'); // I4181 @@ -138,12 +141,21 @@ constructor TProjectUI.Create(AProjectType: TProjectType; AFileName: string); destructor TProjectUI.Destroy; begin FreeAndNil(FRenderFileName); // I4181 + + if FIsTemporary then + begin + if FileExists(FileName) then System.SysUtils.DeleteFile(FileName); + if FileExists(UserFileName) then System.SysUtils.DeleteFile(UserFileName); + end; + inherited Destroy; end; function TProjectUI.DisplayFileName: string; begin - Result := ExtractFileName(FileName); + if IsTemporary + then Result := 'Temporary Project' + else Result := ExtractFileName(FileName); end; function TProjectUI.Load: Boolean; // I4694 diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFilesUI.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFilesUI.pas index 063b1106fa2..61c0321630d 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFilesUI.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectFilesUI.pas @@ -146,8 +146,11 @@ function TOpenableProjectFileUI.GetProjectFile: TOpenableProjectFile; procedure TOpenableProjectFileUI.OpenFile; begin FMDIChild := frmKeymanDeveloper.OpenFile(ProjectFile.FileName, False); - FMDIChild.OnCloseFile := CloseFile; - FMDIChild.ProjectFile := ProjectFile; + if Assigned(FMDIChild) then + begin + FMDIChild.OnCloseFile := CloseFile; + FMDIChild.ProjectFile := ProjectFile; + end; end; function TOpenableProjectFileUI.WindowOpen: Boolean; diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectUI.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectUI.pas index 97e0fb24116..a4ee83c37e6 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectUI.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.ProjectUI.pas @@ -28,13 +28,17 @@ function GetGlobalProjectUI: TProjectUI; function LoadGlobalProjectUI(pt: TProjectType; AFilename: string): TProjectUI; procedure FreeGlobalProjectUI; function IsGlobalProjectUIReady: Boolean; +function CreateTempGlobalProjectUI(pt: TProjectType): TProjectUI; implementation uses System.SysUtils, + Winapi.Windows, - Keyman.Developer.System.Project.Project; + Keyman.Developer.System.TikeMultiProcess, + Keyman.Developer.System.Project.Project, + utildir; function GetGlobalProjectUI: TProjectUI; begin @@ -49,20 +53,53 @@ function IsGlobalProjectUIReady: Boolean; procedure FreeGlobalProjectUI; begin FreeAndNil(FGlobalProject); + TikeMultiProcess.CloseProject; end; -function LoadGlobalProjectUI(pt: TProjectType; AFilename: string): TProjectUI; +function CreateTempGlobalProjectUI(pt: TProjectType): TProjectUI; +var + kpj: TProject; + AFilename: string; begin Assert(not Assigned(FGlobalProject)); - if DirectoryExists(AFilename) then - begin - // Load a directory-based project - if AFilename.EndsWith('\') then - AFilename := AFilename.Substring(0, AFilename.Length-1); - AFilename := AFilename + '\' + ExtractFileName(AFilename) + '.kpj'; + + AFileName := KGetTempFileName('.kpj'); + System.SysUtils.DeleteFile(AFileName); + + kpj := TProject.Create(pt, AFilename, False); + try + kpj.Options.Version := pv10; + kpj.Options.BuildPath := ''; //'$SOURCEPATH'; + kpj.Options.WarnDeprecatedCode := True; + kpj.Options.CompilerWarningsAsErrors := True; + kpj.Options.CheckFilenameConventions := False; + kpj.Options.SkipMetadataFiles := True; + kpj.Save; + finally + kpj.Free; end; - Result := TProjectUI.Create(pt, AFilename); // I4687 + + Result := TProjectUI.Create(pt, AFilename, True); + Result.IsTemporary := True; + + FGlobalProject := Result; + TikeMultiProcess.OpenProject( + '', // We use the empty string to designate a temp project (* is no project) + FGlobalProject.GetTargetFilename(FGlobalProject.Options.SourcePath, + '', '') + ); +end; + +function LoadGlobalProjectUI(pt: TProjectType; AFilename: string): TProjectUI; +begin + Assert(not Assigned(FGlobalProject)); + Result := TProjectUI.Create(pt, AFilename, True); // I4687 FGlobalProject := Result; + TikeMultiProcess.OpenProject( + FGlobalProject.FileName, + FGlobalProject.GetTargetFilename(FGlobalProject.Options.SourcePath, + '', '') + ); end; end. diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProject.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProject.pas index ba73eaf68cf..e1c5a73621e 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProject.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProject.pas @@ -349,8 +349,6 @@ procedure TfrmProject.WebCommandWelcome(Command: WideString; Params: TStringList modActionsMain.actProjectNew.Execute else if Command = 'openproject' then modActionsMain.actProjectOpen.Execute - else if Command = 'openprojectfolder' then - modActionsMain.actProjectOpenFolder.Execute else if Command = 'editfile' then // MRU begin if SelectedMRUFileName <> '' then @@ -442,9 +440,9 @@ procedure TfrmProject.WebCommandProject(Command: WideString; Params: TStringList pf := SelectedProjectFile; if Assigned(pf) then (pf.UI as TProjectFileUI).DefaultEvent(Self) // I4687 else if SelectedMRUFileName <> '' then - frmKeymanDeveloper.OpenFile(SelectedMRUFileName, True) + frmKeymanDeveloper.OpenFilesInProject([SelectedMRUFileName]) else if Params.Values['name'] <> '' then - frmKeymanDeveloper.OpenFile(Params.Values['name'], True); + frmKeymanDeveloper.OpenFilesInProject([Params.Values['name']]); end else if Command = 'viewfilesource' then begin diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProjectSettings20.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProjectSettings20.pas index 2bda4a3bb66..78c945a6935 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProjectSettings20.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmProjectSettings20.pas @@ -1,22 +1,22 @@ (* Name: Keyman.Developer.UI.Project.UfrmProjectSettings Copyright: Copyright (C) SIL International. - Documentation: - Description: + Documentation: + Description: Create Date: 4 May 2015 Modified Date: 24 Aug 2015 Authors: mcdurdin - Related Files: - Dependencies: + Related Files: + Dependencies: - Bugs: - Todo: - Notes: + Bugs: + Todo: + Notes: History: 04 May 2015 - mcdurdin - I4688 - V9.0 - Add build path to project settings 24 Aug 2015 - mcdurdin - I4865 - Add treat hints and warnings as errors into project 24 Aug 2015 - mcdurdin - I4866 - Add warn on deprecated features to project and compile - + *) unit Keyman.Developer.UI.Project.UfrmProjectSettings20; // I4688 @@ -54,12 +54,13 @@ implementation {$R *.dfm} uses - Keyman.Developer.System.Project.Project; + Keyman.Developer.System.Project.Project, + utildir; procedure TfrmProjectSettings20.cmdOKClick(Sender: TObject); begin - FGlobalProject.Options.BuildPath := Trim(editOutputPath.Text); - FGlobalProject.Options.SourcePath := Trim(editSourcePath.Text); + FGlobalProject.Options.BuildPath := Trim(DosSlashes(editOutputPath.Text)); + FGlobalProject.Options.SourcePath := Trim(DosSlashes(editSourcePath.Text)); FGlobalProject.Options.SkipMetadataFiles := not chkBuildMetadataFiles.Checked; FGlobalProject.Options.CompilerWarningsAsErrors := chkCompilerWarningsAsErrors.Checked; // I4865 FGlobalProject.Options.WarnDeprecatedCode := chkWarnDeprecatedCode.Checked; // I4866 diff --git a/developer/src/tike/tike.dpr b/developer/src/tike/tike.dpr index 9a0ff51c9b8..63900f50af0 100644 --- a/developer/src/tike/tike.dpr +++ b/developer/src/tike/tike.dpr @@ -288,7 +288,14 @@ uses Keyman.Developer.UI.Project.xmlLdmlProjectFileUI in 'project\Keyman.Developer.UI.Project.xmlLdmlProjectFileUI.pas', Keyman.Developer.UI.UfrmLdmlKeyboardEditor in 'child\Keyman.Developer.UI.UfrmLdmlKeyboardEditor.pas' {frmLdmlKeyboardEditor}, dmActionsKeyboardEditor in 'actions\dmActionsKeyboardEditor.pas' {modActionsKeyboardEditor: TDataModule}, - Keyman.Developer.UI.Project.UfrmProjectSettings in 'project\Keyman.Developer.UI.Project.UfrmProjectSettings.pas' {frmProjectSettings}; + Keyman.Developer.UI.Project.UfrmProjectSettings in 'project\Keyman.Developer.UI.Project.UfrmProjectSettings.pas' {frmProjectSettings}, + Keyman.Developer.System.TikeCommandLine in 'main\Keyman.Developer.System.TikeCommandLine.pas', + Keyman.Developer.System.MultiProcess in 'main\Keyman.Developer.System.MultiProcess.pas', + Keyman.Developer.System.TikeMultiProcess in 'main\Keyman.Developer.System.TikeMultiProcess.pas', + Keyman.System.CopyDataHelper in 'main\Keyman.System.CopyDataHelper.pas', + Keyman.Developer.System.ProjectOwningFile in 'main\Keyman.Developer.System.ProjectOwningFile.pas', + Keyman.Developer.System.Main in 'main\Keyman.Developer.System.Main.pas', + Keyman.Developer.System.LaunchProjects in 'main\Keyman.Developer.System.LaunchProjects.pas'; {$R *.RES} {$R ICONS.RES} @@ -299,40 +306,6 @@ uses // If you don't add this flag the rederer process will crash when you try to load large images. {$SetPEFlags IMAGE_FILE_LARGE_ADDRESS_AWARE} -const - LOGGER_DEVELOPER_IDE_TIKE = TKeymanSentryClient.LOGGER_DEVELOPER_IDE + '.tike'; begin - TKeymanSentryClient.Start(TSentryClientVcl, kscpDeveloper, LOGGER_DEVELOPER_IDE_TIKE); - try - try - CoInitFlags := COINIT_APARTMENTTHREADED; - - FInitializeCEF := TCEFManager.Create; - try - if FInitializeCEF.Start then - begin - InitThemeLibrary; - SetThemeAppProperties(STAP_ALLOW_NONCLIENT or STAP_ALLOW_CONTROLS or STAP_ALLOW_WEBCONTENT); - Application.MainFormOnTaskBar := True; - Application.Initialize; - Application.Title := 'Keyman Developer'; - Application.CreateForm(TmodWebHttpServer, modWebHttpServer); - try - Application.CreateForm(TfrmKeymanDeveloper, frmKeymanDeveloper); - Application.Run; - finally - FreeAndNil(frmKeymanDeveloper); - FreeAndNil(modWebHttpServer); - end; - end; - finally - FInitializeCEF.Free; - end; - except - on E:Exception do - SentryHandleException(E); - end; - finally - TKeymanSentryClient.Stop; - end; + RunKeymanDeveloper; end. diff --git a/developer/src/tike/tike.dproj b/developer/src/tike/tike.dproj index cb9b54134cd..ea847fbc406 100644 --- a/developer/src/tike/tike.dproj +++ b/developer/src/tike/tike.dproj @@ -117,6 +117,7 @@ 1033 Debug true + c:\temp\Bangla.kmn @@ -576,6 +577,13 @@
frmProjectSettings
dfm + + + + + + + Cfg_2 diff --git a/developer/src/tike/xml/project/distribution.xsl b/developer/src/tike/xml/project/distribution.xsl index 840a3287c06..d30403e84a9 100644 --- a/developer/src/tike/xml/project/distribution.xsl +++ b/developer/src/tike/xml/project/distribution.xsl @@ -83,6 +83,7 @@ false + true diff --git a/developer/src/tike/xml/project/elements.xsl b/developer/src/tike/xml/project/elements.xsl index 0bcf796cc29..ba5a41fdee6 100644 --- a/developer/src/tike/xml/project/elements.xsl +++ b/developer/src/tike/xml/project/elements.xsl @@ -76,6 +76,7 @@ + file @@ -103,7 +104,10 @@
diff --git a/developer/src/tike/xml/project/globalwelcome.xsl b/developer/src/tike/xml/project/globalwelcome.xsl index 6f956bd2336..aecd70d90ff 100644 --- a/developer/src/tike/xml/project/globalwelcome.xsl +++ b/developer/src/tike/xml/project/globalwelcome.xsl @@ -57,10 +57,6 @@ Open Existing Project... keyman:openproject - - Open Existing Project Folder... - keyman:openprojectfolder - diff --git a/linux/debian/changelog b/linux/debian/changelog index 1ad0f861fb3..49904e12ad0 100644 --- a/linux/debian/changelog +++ b/linux/debian/changelog @@ -1,3 +1,22 @@ +keyman (16.0.144-1) unstable; urgency=medium + + [Dandan Zhang] + * Add support for loong64 (closes: #1057134) + + [Eberhard Beilharz] + * New upstream release. + * Re-release to Debian + + -- Eberhard Beilharz Fri, 01 Dec 2023 14:59:28 +0100 + +keyman (16.0.143-1) unstable; urgency=medium + + * Fix failure to build source after successful build (Closes #1046776) + * New upstream release. + * Re-release to Debian + + -- Eberhard Beilharz Wed, 22 Nov 2023 15:24:59 +0100 + keyman (16.0.141-1) unstable; urgency=medium * Work around mips64el build failure (#1041499) diff --git a/linux/debian/control b/linux/debian/control index acf64ea7f6a..f3ba49bd8de 100644 --- a/linux/debian/control +++ b/linux/debian/control @@ -109,7 +109,7 @@ Description: Keyman for Linux configuration information about Keyman keyboard packages. Package: libkeymancore-dev -Architecture: amd64 arm64 armel armhf i386 mipsel mips64el ppc64el riscv64 +Architecture: amd64 arm64 armel armhf i386 loong64 mipsel mips64el ppc64el riscv64 Section: libdevel Depends: libkeymancore (= ${binary:Version}), @@ -135,7 +135,7 @@ Description: Development files for Keyman keyboard processing library This package contains development headers and libraries. Package: libkeymancore -Architecture: amd64 arm64 armel armhf i386 mipsel mips64el ppc64el riscv64 +Architecture: amd64 arm64 armel armhf i386 loong64 mipsel mips64el ppc64el riscv64 Section: libs Pre-Depends: ${misc:Pre-Depends}, @@ -163,7 +163,7 @@ Description: Keyman keyboard processing library and applies rules from compiled Keyman keyboard files. Package: ibus-keyman -Architecture: amd64 arm64 armel armhf i386 mipsel mips64el ppc64el riscv64 +Architecture: amd64 arm64 armel armhf i386 loong64 mipsel mips64el ppc64el riscv64 Depends: ibus (>= 1.3.7), libkeymancore (= ${binary:Version}), diff --git a/linux/debian/tests/control b/linux/debian/tests/control index ca633c65717..0a939f73743 100644 --- a/linux/debian/tests/control +++ b/linux/debian/tests/control @@ -3,4 +3,4 @@ Depends: @, build-essential, libicu-dev, pkg-config, -Architecture: amd64 arm64 armel armhf i386 mips64el mipsel ppc64el riscv64 +Architecture: amd64 arm64 armel armhf i386 loong64 mips64el mipsel ppc64el riscv64 diff --git a/package-lock.json b/package-lock.json index 359a098c95b..750b36a8bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "developer/src/kmc-package", "developer/src/kmc", "developer/src/server", - "developer/utils", "common/models/*", "common/test/resources", "common/tools/*", @@ -679,6 +678,7 @@ }, "developer/src/common/web/utils": { "name": "@keymanapp/developer-utils", + "license": "MIT", "dependencies": { "@keymanapp/common-types": "*" }, @@ -835,9 +835,6 @@ }, "developer/src/kmc": { "name": "@keymanapp/kmc", - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "license": "MIT", "dependencies": { "@keymanapp/common-types": "*", @@ -1135,9 +1132,6 @@ }, "developer/src/kmc-keyboard-info": { "name": "@keymanapp/kmc-keyboard-info", - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "license": "MIT", "dependencies": { "@keymanapp/common-types": "*", @@ -1971,9 +1965,6 @@ }, "developer/src/kmc-model-info": { "name": "@keymanapp/kmc-model-info", - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "license": "MIT", "dependencies": { "@keymanapp/common-types": "*", @@ -2762,11 +2753,9 @@ }, "developer/src/server": { "name": "@keymanapp/developer-server", - "bundleDependencies": [ - "@keymanapp/developer-utils" - ], "license": "MIT", "dependencies": { + "@keymanapp/developer-utils": "*", "@sentry/node": "^6.16.1", "chalk": "^4.1.2", "express": "^4.17.2", @@ -2776,7 +2765,6 @@ "ws": "^8.3.0" }, "devDependencies": { - "@keymanapp/developer-utils": "*", "@keymanapp/keyman-version": "*", "@keymanapp/resources-gosh": "*", "@types/chai": "^4.3.0", diff --git a/package.json b/package.json index 01f3f387f0b..d7490161434 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "developer/src/kmc-package", "developer/src/kmc", "developer/src/server", - "developer/utils", "common/models/*", "common/test/resources", "common/tools/*", diff --git a/web/.c8rc.json b/web/.c8rc.json new file mode 100644 index 00000000000..f308b484873 --- /dev/null +++ b/web/.c8rc.json @@ -0,0 +1,13 @@ +{ + "all": true, + "check-coverage": false, + "clean": true, + "exclude-after-remap": true, + "extension": [".ts", ".js"], + "reporter": ["text", "text-summary"], + "reports-dir": "build/coverage", + "src": [ + "**/src/", + "../common/**/src" + ] +} diff --git a/web/build.sh b/web/build.sh index 51df8a346da..8e7c1ac0b07 100755 --- a/web/build.sh +++ b/web/build.sh @@ -21,6 +21,7 @@ builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ "configure" \ "build" \ "test" \ + "coverage Create an HTML page with code coverage" \ ":app/browser The form of Keyman Engine for Web for use on websites" \ ":app/webview A puppetable version of KMW designed for use in a host app's WebView" \ ":app/ui Builds KMW's desktop form-factor keyboard-selection UI modules" \ @@ -113,3 +114,15 @@ if builder_start_action test; then builder_finish_action success test fi + +coverage_action() { + builder_echo "Creating coverage report..." + cd "$KEYMAN_ROOT" + mkdir -p web/build/coverage/tmp + find . -type f -name coverage-\*.json -print0 | xargs -0 cp -t web/build/coverage/tmp + c8 report --config web/.c8rc.json ---reporter html --clean=false --reports-dir=web/build/coverage + rm -rf web/build/coverage/tmp + cd web +} + +builder_run_action coverage coverage_action diff --git a/web/src/app/browser/src/hardwareEventKeyboard.ts b/web/src/app/browser/src/hardwareEventKeyboard.ts index df03783d798..be49ad10de1 100644 --- a/web/src/app/browser/src/hardwareEventKeyboard.ts +++ b/web/src/app/browser/src/hardwareEventKeyboard.ts @@ -267,7 +267,7 @@ export default class HardwareEventKeyboard extends HardKeyboard { } }); - page.off('disabled', (Pelem) => { + page.on('disabled', (Pelem) => { const target = outputTargetForElement(Pelem); if(!(target instanceof DesignIFrame)) { @@ -335,7 +335,7 @@ export default class HardwareEventKeyboard extends HardKeyboard { _KeyUp: (e: KeyboardEvent) => boolean = (e) => { const target = eventOutputTarget(e); var Levent = preprocessKeyboardEvent(e, this.processor, this.hardDevice); - if(Levent == null) { + if(Levent == null || target == null) { return true; } diff --git a/web/src/engine/attachment/src/outputTargetForElement.ts b/web/src/engine/attachment/src/outputTargetForElement.ts index 78b9494dc8d..2b55fa8e4b7 100644 --- a/web/src/engine/attachment/src/outputTargetForElement.ts +++ b/web/src/engine/attachment/src/outputTargetForElement.ts @@ -42,5 +42,5 @@ export function outputTargetForElement(Ltarg: HTMLElement) { // Step 2: With the most likely host element determined, obtain the corresponding OutputTarget // instance. - return Ltarg._kmwAttachment.interface; + return Ltarg._kmwAttachment?.interface; } \ No newline at end of file diff --git a/web/src/engine/main/src/keyboardInterface.ts b/web/src/engine/main/src/keyboardInterface.ts index 305373aea32..971bcdcab5b 100644 --- a/web/src/engine/main/src/keyboardInterface.ts +++ b/web/src/engine/main/src/keyboardInterface.ts @@ -88,15 +88,21 @@ export default class KeyboardInterface { + const pathConfig = this.engine.config.paths; + return new KeyboardStub(Pstub, pathConfig.keyboards, pathConfig.fonts); + }; if(!this.engine.config.deferForInitialization.hasFinalized) { - this.engine.config.deferForInitialization.then(() => this.engine.keyboardRequisitioner.cache.addStub(stub)); + // pathConfig is not ready until KMW initializes, which prevents proper stub-building. + this.engine.config.deferForInitialization.then(() => this.engine.keyboardRequisitioner.cache.addStub(buildStub())); } else { + const stub = buildStub(); + + if(this.engine.keyboardRequisitioner?.cache.findMatchingStub(stub)) { + return 1; + } this.engine.keyboardRequisitioner.cache.addStub(stub); } diff --git a/web/src/engine/osk/src/banner/bannerController.ts b/web/src/engine/osk/src/banner/bannerController.ts index 4f77524a1b4..45ee746bbfa 100644 --- a/web/src/engine/osk/src/banner/bannerController.ts +++ b/web/src/engine/osk/src/banner/bannerController.ts @@ -68,7 +68,8 @@ export class BannerController { const oldBanner = this.container.banner; if(oldBanner instanceof SuggestionBanner) { - this.predictionContext.off('update', oldBanner.onSuggestionUpdate); + // Frees all handlers, etc registered previously by the banner. + oldBanner.predictionContext = null; } if(!on) { @@ -78,7 +79,7 @@ export class BannerController { suggestBanner.predictionContext = this.predictionContext; suggestBanner.events.on('apply', (selection) => this.predictionContext.accept(selection.suggestion)); - this.predictionContext.on('update', suggestBanner.onSuggestionUpdate); + // Registers for prediction-engine events & handles its needed connections. this.container.banner = suggestBanner; } } @@ -92,4 +93,10 @@ export class BannerController { // Only display a SuggestionBanner when LanguageProcessor states it is active. this.activateBanner(state == 'active' || state == 'configured'); } + + public shutdown() { + if(this.container.banner instanceof SuggestionBanner) { + this.container.banner.predictionContext = null; + } + } } \ No newline at end of file diff --git a/web/src/engine/osk/src/views/oskView.ts b/web/src/engine/osk/src/views/oskView.ts index 0399991fe08..ebe20db18fa 100644 --- a/web/src/engine/osk/src/views/oskView.ts +++ b/web/src/engine/osk/src/views/oskView.ts @@ -690,6 +690,7 @@ export default abstract class OSKView extends EventEmitter implements private loadActiveKeyboard() { this.setBoxStyling(); + // Do not erase / 'shutdown' the banner-controller; we simply re-use its elements. if(this.vkbd) { this.vkbd.shutdown(); } @@ -1135,6 +1136,8 @@ export default abstract class OSKView extends EventEmitter implements this.kbdStyleSheetManager.unlinkAll(); this.uiStyleSheetManager.unlinkAll(); + + this.bannerController.shutdown(); } /** diff --git a/web/src/test/manual/web/attachment-api/utilities.js b/web/src/test/manual/web/attachment-api/utilities.js index a0c15fd3afb..5793c12af6f 100644 --- a/web/src/test/manual/web/attachment-api/utilities.js +++ b/web/src/test/manual/web/attachment-api/utilities.js @@ -1,6 +1,7 @@ // Page-global variable definitions. { var inputCounter = 0; +var kmw = keyman; } function generateDiagnosticDiv(elem) { diff --git a/web/src/test/manual/web/index.html b/web/src/test/manual/web/index.html index 61ee99d2de4..e98979f094e 100644 --- a/web/src/test/manual/web/index.html +++ b/web/src/test/manual/web/index.html @@ -66,6 +66,7 @@

Test start of sentence keyboar

Tests predictive text & other handling of rule matching when the final rule group does not match (#6005)

Tests handling of new default-subkey feature (#9430)

Test special characters rendering with keymanweb-osk.ttf (#9469)

+

Test text selection (#9073)

Other

Keystroke processing regression test engine.


diff --git a/web/src/test/manual/web/text_selection_tests_9073/index.html b/web/src/test/manual/web/text_selection_tests_9073/index.html new file mode 100644 index 00000000000..20bd3cda686 --- /dev/null +++ b/web/src/test/manual/web/text_selection_tests_9073/index.html @@ -0,0 +1,78 @@ + + + + + + + + + KeymanWeb #9073 + + + + + + + + + + + + + + +

Text Selection Test Cases (#9073)

+ +
+ +
+ + +
+ +
+

Return to testing home page

+ + + + diff --git a/web/src/test/manual/web/text_selection_tests_9073/text_selection_tests_keyboard_9073.js b/web/src/test/manual/web/text_selection_tests_9073/text_selection_tests_keyboard_9073.js new file mode 100644 index 00000000000..f1e93eb48e7 --- /dev/null +++ b/web/src/test/manual/web/text_selection_tests_9073/text_selection_tests_keyboard_9073.js @@ -0,0 +1,641 @@ +if(typeof keyman === 'undefined') { + console.log('Keyboard requires KeymanWeb 10.0 or later'); + if(typeof tavultesoft !== 'undefined') tavultesoft.keymanweb.util.alert("This keyboard requires KeymanWeb 10.0 or later"); +} else { +KeymanWeb.KR(new Keyboard_text_selection_tests_keyboard_9073()); +} +function Keyboard_text_selection_tests_keyboard_9073() +{ + var modCodes = keyman.osk.modifierCodes; + var keyCodes = keyman.osk.keyCodes; + + this._v=(typeof keyman!="undefined"&&typeof keyman.version=="string")?parseInt(keyman.version,10):9; + this.KI="Keyboard_text_selection_tests_keyboard_9073"; + this.KN="Text Selection Tests Keyboard"; + this.KMINVER="10.0"; + this.KV={F:' 1em "Arial"',K102:0}; + this.KV.KLS={ + "default": ["dk(1)","1","2","3","4","5","6","7","8","9","0","-","=","","","","q","w","e","r","t","y","u","i","o","p","[","]","\\","","","","a","s","d","f","g","h","j","k","l",";","'","","","","","","\\","z","x","c","v","b","n","m",",",".","/","","","","","",""], + "shift": ["~","!","@","#","$","%","^","&","*","(",")","_","+","","","","Q","W","E","R","T","Y","U","I","O","P","{","}","|","","","","A","S","D","F","G","H","J","K","L",":","\"","","","","","","|","Z","X","C","V","B","N","M","<",">","?","","","","","",""] + }; + this.KV.BK=(function(x){ + var + empty=Array.apply(null, Array(65)).map(String.prototype.valueOf,""), + result=[], v, i, + modifiers=['default','shift','ctrl','shift-ctrl','alt','shift-alt','ctrl-alt','shift-ctrl-alt']; + for(i=modifiers.length-1;i>=0;i--) { + v = x[modifiers[i]]; + if(v || result.length > 0) { + result=(v ? v : empty).slice().concat(result); + } + } + return result; + })(this.KV.KLS); + this.KDU=0; + this.KH=''; + this.KM=0; + this.KBVER="1.0"; + this.KMBM=modCodes.SHIFT /* 0x0010 */; + this.KVKL={ + "tablet": { + "displayUnderlying": false, + "layer": [ + { + "id": "default", + "row": [ + { + "id": "1", + "key": [ + { + "nextlayer": "shift", + "id": "K_1", + "text": "1" + }, + { + "id": "K_2", + "text": "2" + }, + { + "id": "K_3", + "text": "3" + }, + { + "id": "K_4", + "text": "4" + }, + { + "id": "K_5", + "text": "5" + }, + { + "id": "K_6", + "text": "6" + }, + { + "id": "K_7", + "text": "7" + }, + { + "id": "K_8", + "text": "8" + }, + { + "id": "K_9", + "text": "9" + }, + { + "id": "K_0", + "text": "0" + }, + { + "id": "K_HYPHEN", + "text": "-" + }, + { + "id": "K_EQUAL", + "text": "=" + }, + { + "width": "100", + "id": "K_BKSP", + "sp": "1", + "text": "*BkSp*" + } + ] + }, + { + "id": "2", + "key": [ + { + "id": "K_Q", + "pad": "75", + "text": "q" + }, + { + "id": "K_W", + "text": "w" + }, + { + "id": "K_E", + "text": "e" + }, + { + "id": "K_R", + "text": "r" + }, + { + "id": "K_T", + "text": "t" + }, + { + "id": "K_Y", + "text": "y" + }, + { + "id": "K_U", + "text": "u" + }, + { + "id": "K_I", + "text": "i" + }, + { + "id": "K_O", + "text": "o" + }, + { + "id": "K_P", + "text": "p" + }, + { + "id": "K_LBRKT", + "text": "[" + }, + { + "id": "K_RBRKT", + "text": "]" + }, + { + "width": "10", + "id": "T_new_136", + "sp": "10" + } + ] + }, + { + "id": "3", + "key": [ + { + "id": "K_BKQUOTE", + "text": "dk(1)" + }, + { + "id": "K_A", + "text": "a" + }, + { + "id": "K_S", + "text": "s" + }, + { + "id": "K_D", + "text": "d" + }, + { + "id": "K_F", + "text": "f" + }, + { + "id": "K_G", + "text": "g" + }, + { + "id": "K_H", + "text": "h" + }, + { + "id": "K_J", + "text": "j" + }, + { + "id": "K_K", + "text": "k" + }, + { + "id": "K_L", + "text": "l" + }, + { + "id": "K_COLON", + "text": ";" + }, + { + "id": "K_QUOTE", + "text": "'" + }, + { + "id": "K_BKSLASH", + "text": "\\" + } + ] + }, + { + "id": "4", + "key": [ + { + "nextlayer": "shift", + "width": "160", + "id": "K_SHIFT", + "sp": "1", + "text": "*Shift*" + }, + { + "id": "K_oE2", + "text": "\\" + }, + { + "id": "K_Z", + "text": "z" + }, + { + "id": "K_X", + "text": "x" + }, + { + "id": "K_C", + "text": "c" + }, + { + "id": "K_V", + "text": "v" + }, + { + "id": "K_B", + "text": "b" + }, + { + "id": "K_N", + "text": "n" + }, + { + "id": "K_M", + "text": "m" + }, + { + "id": "K_COMMA", + "text": "," + }, + { + "id": "K_PERIOD", + "text": "." + }, + { + "id": "K_SLASH", + "text": "/" + }, + { + "width": "10", + "id": "T_new_162", + "sp": "10" + } + ] + }, + { + "id": "5", + "key": [ + { + "width": "140", + "id": "K_LOPT", + "sp": "1", + "text": "*Menu*" + }, + { + "width": "930", + "id": "K_SPACE" + }, + { + "width": "145", + "id": "K_ENTER", + "sp": "1", + "text": "*Enter*" + } + ] + } + ] + }, + { + "id": "shift", + "row": [ + { + "id": "1", + "key": [ + { + "id": "K_1", + "text": "!" + }, + { + "id": "K_2", + "text": "@" + }, + { + "id": "K_3", + "text": "#" + }, + { + "id": "K_4", + "text": "$" + }, + { + "id": "K_5", + "text": "%" + }, + { + "id": "K_6", + "text": "^" + }, + { + "id": "K_7", + "text": "&" + }, + { + "id": "K_8", + "text": "*" + }, + { + "id": "K_9", + "text": "(" + }, + { + "id": "K_0", + "text": ")" + }, + { + "id": "K_HYPHEN", + "text": "_" + }, + { + "id": "K_EQUAL", + "text": "+" + }, + { + "width": "100", + "id": "K_BKSP", + "sp": "1", + "text": "*BkSp*" + } + ] + }, + { + "id": "2", + "key": [ + { + "id": "K_Q", + "pad": "75", + "text": "Q" + }, + { + "id": "K_W", + "text": "W" + }, + { + "id": "K_E", + "text": "E" + }, + { + "id": "K_R", + "text": "R" + }, + { + "id": "K_T", + "text": "T" + }, + { + "id": "K_Y", + "text": "Y" + }, + { + "id": "K_U", + "text": "U" + }, + { + "id": "K_I", + "text": "I" + }, + { + "id": "K_O", + "text": "O" + }, + { + "id": "K_P", + "text": "P" + }, + { + "id": "K_LBRKT", + "text": "{" + }, + { + "id": "K_RBRKT", + "text": "}" + }, + { + "width": "10", + "id": "T_new_246", + "sp": "10" + } + ] + }, + { + "id": "3", + "key": [ + { + "id": "K_BKQUOTE", + "text": "~" + }, + { + "id": "K_A", + "text": "A" + }, + { + "id": "K_S", + "text": "S" + }, + { + "id": "K_D", + "text": "D" + }, + { + "id": "K_F", + "text": "F" + }, + { + "id": "K_G", + "text": "G" + }, + { + "id": "K_H", + "text": "H" + }, + { + "id": "K_J", + "text": "J" + }, + { + "id": "K_K", + "text": "K" + }, + { + "id": "K_L", + "text": "L" + }, + { + "id": "K_COLON", + "text": ":" + }, + { + "id": "K_QUOTE", + "text": "\"" + }, + { + "id": "K_BKSLASH", + "text": "|" + } + ] + }, + { + "id": "4", + "key": [ + { + "nextlayer": "default", + "width": "160", + "id": "K_SHIFT", + "sp": "1", + "text": "*Shift*" + }, + { + "id": "K_oE2", + "text": "|" + }, + { + "id": "K_Z", + "text": "Z" + }, + { + "id": "K_X", + "text": "X" + }, + { + "id": "K_C", + "text": "C" + }, + { + "id": "K_V", + "text": "V" + }, + { + "id": "K_B", + "text": "B" + }, + { + "id": "K_N", + "text": "N" + }, + { + "id": "K_M", + "text": "M" + }, + { + "id": "K_COMMA", + "text": "<" + }, + { + "id": "K_PERIOD", + "text": ">" + }, + { + "id": "K_SLASH", + "text": "?" + }, + { + "width": "10", + "id": "T_new_272", + "sp": "10" + } + ] + }, + { + "id": "5", + "key": [ + { + "width": "140", + "id": "K_LOPT", + "sp": "1", + "text": "*Menu*" + }, + { + "width": "930", + "id": "K_SPACE" + }, + { + "width": "145", + "id": "K_ENTER", + "sp": "1", + "text": "*Enter*" + } + ] + } + ] + } + ] + } +} +; + this.KVER="16.0.142.0"; + this.KVS=[]; + this.gs=function(t,e) { + return this.g_main_0(t,e); + }; + this.gs=function(t,e) { + return this.g_main_0(t,e); + }; + this.g_main_0=function(t,e) { + var k=KeymanWeb,r=0,m=0; + if(k.KKM(e, modCodes.VIRTUAL_KEY /* 0x4000 */, keyCodes.K_BKSP /* 0x08 */)) { + if(k.KFCM(3,t,['a',{t:'d',d:0},'b'])){ + r=m=1; // Line 23 + k.KDC(3,t); + k.KO(-1,t,"ok1"); + } + else if(k.KFCM(2,t,['a','b'])){ + r=m=1; // Line 24 + k.KDC(2,t); + k.KO(-1,t,"fail1"); + } + else if(k.KFCM(2,t,['a',{t:'d',d:0}])){ + r=m=1; // Line 25 + k.KDC(2,t); + k.KO(-1,t,"fail2"); + } + else if(k.KFCM(1,t,['^'])){ + r=m=1; // Line 17 + k.KDC(1,t); + k.KO(-1,t,"foo"); + } + } + else if(k.KKM(e, modCodes.SHIFT | modCodes.VIRTUAL_KEY /* 0x4010 */, keyCodes.K_A /* 0x41 */)) { + if(k.KFCM(1,t,['^'])){ + r=m=1; // Line 16 + k.KDC(1,t); + k.KO(-1,t,"Â"); + } + } + else if(k.KKM(e, modCodes.VIRTUAL_KEY /* 0x4000 */, keyCodes.K_BKQUOTE /* 0xC0 */)) { + if(1){ + r=m=1; // Line 19 + k.KDC(0,t); + k.KDO(-1,t,0); + } + } + else if(k.KKM(e, modCodes.VIRTUAL_KEY /* 0x4000 */, keyCodes.K_A /* 0x41 */)) { + if(k.KFCM(1,t,['^'])){ + r=m=1; // Line 15 + k.KDC(1,t); + k.KO(-1,t,"â"); + } + } + else if(k.KKM(e, modCodes.VIRTUAL_KEY /* 0x4000 */, keyCodes.K_O /* 0x4F */)) { + if(k.KFCM(1,t,[{t:'d',d:0}])){ + r=m=1; // Line 26 + k.KDC(1,t); + k.KO(-1,t,"ok3"); + } + } + else if(k.KKM(e, modCodes.VIRTUAL_KEY /* 0x4000 */, keyCodes.K_T /* 0x54 */)) { + if(1){ + r=m=1; // Line 21 + k.KDC(0,t); + k.KO(-1,t,"\t"); + } + } + return r; + }; +} diff --git a/windows/src/engine/keyman32/appcontext.cpp b/windows/src/engine/keyman32/appcontext.cpp new file mode 100644 index 00000000000..de68efe0ae7 --- /dev/null +++ b/windows/src/engine/keyman32/appcontext.cpp @@ -0,0 +1,166 @@ +#include "pch.h" +// AppContext Class Methods +AppContext::AppContext() { + Reset(); +} + +WCHAR * +AppContext::BufMax(int n) { + WCHAR *p = wcschr(CurContext, 0); // I3091 + + if (CurContext == p || n == 0) + return p; /* empty context or 0 characters requested, return pointer to end of context */ // I3091 + + WCHAR *q = p; // I3091 + for (; p != NULL && p > CurContext && (INT_PTR)(q - p) < n; p = decxstr(p, CurContext)) + ; // I3091 + + if ((INT_PTR)(q - p) > n) + p = incxstr(p); /* Copes with deadkey or supplementary pair at start of returned buffer making it too long */ // I3091 + + return p; // I3091 +} + +void +AppContext::Delete() { + if (CharIsDeadkey()) { + pos -= 2; + } else if (CharIsSurrogatePair()) { + pos--; + } + // SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext::Delete"); + + if (pos > 0) + pos--; + CurContext[pos] = 0; + // if(--pos < 0) pos = 0; + // SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: Delete"); +} + +void +AppContext::Reset() { + pos = 0; + CurContext[0] = 0; + + // SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: Reset"); +} + +void +AppContext::Get(WCHAR *buf, int bufsize) { + // surrogate pairs need to be treated as a single unit, therefore use + // BufMax to find a start index. + // BufMax handles the case where a surrogate pair at the + // start of the buffer is split by bufsize + for (WCHAR *p = this->BufMax(bufsize); *p && bufsize > 0; p++, bufsize--) { + *buf = *p; + if (Uni_IsSurrogate1(*p) && bufsize - 2 > 0) { + buf++; + p++; + *buf = *p; + bufsize--; + } + buf++; + } + + *buf = 0; +} + +void +AppContext::Set(const WCHAR *buf) { + const WCHAR *p; + WCHAR *q; + + // We may be past a buffer longer than our internal + // buffer. So we shift to make sure we capture the end + // of the string, not the start + p = wcschr(buf, 0); + q = (WCHAR *)p; + while (p != NULL && p > buf && (intptr_t)(q - p) < MAXCONTEXT - 1) { + p = decxstr((WCHAR *)p, (WCHAR *)buf); + } + + // If the first character in the buffer is a surrogate pair, + // or a deadkey, our buffer may be too long, so move to the + // next character in the buffer + if ((intptr_t)(q - p) > MAXCONTEXT - 1) { + p = incxstr((WCHAR *)p); + } + + for (q = CurContext; *p; p++, q++) { + *q = *p; + } + + *q = 0; + pos = (int)(intptr_t)(q - CurContext); + CurContext[MAXCONTEXT - 1] = 0; +} + +BOOL +AppContext::CharIsDeadkey() { + if (pos < 3) // code_sentinel, deadkey, #, 0 + return FALSE; + return CurContext[pos - 3] == UC_SENTINEL && CurContext[pos - 2] == CODE_DEADKEY; +} + +BOOL +AppContext::CharIsSurrogatePair() { + if (pos < 2) // low_surrogate, high_surrogate + return FALSE; + + return Uni_IsSurrogate1(CurContext[pos - 2]) && Uni_IsSurrogate2(CurContext[pos - 1]); +} + +BOOL +AppContext::IsEmpty() { + return (BOOL)(pos == 0); +} + +BOOL +ContextItemToAppContext(km_core_context_item *contextItems, PWSTR outBuf, DWORD len) { + assert(contextItems); + assert(outBuf); + + km_core_context_item *km_core_context_it = contextItems; + uint8_t contextLen = 0; + for (; km_core_context_it->type != KM_CORE_CT_END; ++km_core_context_it) { + ++contextLen; + } + + WCHAR *buf = new WCHAR[(contextLen * 3) + 1]; // *3 if every context item was a deadkey + uint8_t idx = 0; + km_core_context_it = contextItems; + for (; km_core_context_it->type != KM_CORE_CT_END; ++km_core_context_it) { + switch (km_core_context_it->type) { + case KM_CORE_CT_CHAR: + if (Uni_IsSMP(km_core_context_it->character)) { + buf[idx++] = static_cast Uni_UTF32ToSurrogate1(km_core_context_it->character); + buf[idx++] = static_cast Uni_UTF32ToSurrogate2(km_core_context_it->character); + } else { + buf[idx++] = (km_core_cp)km_core_context_it->character; + } + break; + case KM_CORE_CT_MARKER: + assert(km_core_context_it->marker > 0); + buf[idx++] = UC_SENTINEL; + buf[idx++] = CODE_DEADKEY; + buf[idx++] = static_cast(km_core_context_it->marker); + break; + } + } + + buf[idx] = 0; // Null terminate character array + + if (wcslen(buf) > len) { + // Truncate to length 'len' using AppContext so that the context closest to the caret is preserved + // and the truncation will not split deadkeys or surrogate pairs + // Note by using the app context class we will truncate the context to the MAXCONTEXT length if 'len' + // is greater than MAXCONTEXT + AppContext context; + context.Set(buf); + context.Get(outBuf, len); + } else { + wcscpy_s(outBuf, wcslen(buf) + 1, buf); + } + delete[] buf; + return TRUE; +} diff --git a/windows/src/engine/keyman32/appcontext.h b/windows/src/engine/keyman32/appcontext.h new file mode 100644 index 00000000000..d0859262dfd --- /dev/null +++ b/windows/src/engine/keyman32/appcontext.h @@ -0,0 +1,107 @@ +#ifndef _APPCONTEXT_H +#define _APPCONTEXT_H +/* + Name: appcontext + Copyright: Copyright (C) SIL International. + Documentation: + Description: + Create Date: 23 Nov 2023 + + Modified Date: 23 Nov 2023 + Authors: rcruickshank + Related Files: + Dependencies: + + Bugs: + Todo: + Notes: AppContext is retained to support calldll with the external interface for the 3rd party IMX keyboards + that worked with KMX formatted Context Strings. It is also used once for debug logging the ProcessHook. + History: +*/ + +class AppContext { +private: + WCHAR CurContext[MAXCONTEXT]; //!< CurContext[0] is furthest from the caret and buffer is null terminated. + int pos; + +public: + AppContext(); + + /** + * Removes a single code point from the end of the CurContext closest to the caret; + * i.e. it will be both code units if a surrogate pair. If it is a deadkey it will + * remove three code points: UC_SENTINEL, CODE_DEADKEY and deadkey value. + */ + void Delete(); + + /** + * Clears the CurContext and resets the position - pos - index + */ + void Reset(); + + /** + * Copies the characters in CurContext to supplied buffer. + * If bufsize is reached before the entire context was copied, the buf + * will be truncated to number of valid characters possible with null character + * termination. e.g. it will be one code unit less than bufsize if that would + * have meant splitting a surrogate pair + * @param buf The data buffer to copy current context + * @param bufsize The number of code units ie size of the WCHAR buffer - not the code points + */ + void Get(WCHAR *buf, int bufsize); + + /** + * Sets the CurContext to the supplied buf character array and updates the pos index. + * + * @param buf + */ + void Set(const WCHAR *buf); + + /** + * Returns a pointer to the character in the current context buffer which + * will have at most n valid xstring units remaining until the null terminating + * character. It will be one code unit less than bufsize if that would + * have meant splitting a surrogate pair or deadkey. + * + * @param n The maximum number of valid xstring units (not code points or code units) + * @return WCHAR* Pointer to the start postion for a buffer of maximum n xstring units + */ + WCHAR *BufMax(int n); + + /** + * Returns TRUE if the last xstring unit in the context is a deadkey + * + * @return BOOL + */ + BOOL CharIsDeadkey(); + + /** + * Returns TRUE if the last xstring unit in the CurContext is a surrogate pair. + * @return BOOL + */ + BOOL CharIsSurrogatePair(); + + /** + * Returns TRUE if the context is empty + * @return BOOL + */ + BOOL AppContext::IsEmpty(); +}; + +/** + * Convert km_core_context_item array into an kmx char buffer. + * Caller is responsible for freeing the memory. + * The length is restricted to a maximum of MAXCONTEXT length. If the number + * of input km_core_context_items exceeds this length the characters furthest + * from the caret will be truncated. + * + * @param contextItems the input core context array. (km_core_context_item) + * @param [out] outBuf the kmx character array output. caller to free memory. + * + * @return BOOL True if array created successfully + */ +BOOL ContextItemToAppContext(km_core_context_item *contextItems, PWSTR outBuf, DWORD len); + + + +#endif diff --git a/windows/src/engine/keyman32/appint/aiTIP.cpp b/windows/src/engine/keyman32/appint/aiTIP.cpp index a7faa7170fe..93ffcb445d2 100644 --- a/windows/src/engine/keyman32/appint/aiTIP.cpp +++ b/windows/src/engine/keyman32/appint/aiTIP.cpp @@ -269,89 +269,27 @@ char *debugstr(PWSTR buf) { /* Context functions */ -void AITIP::MergeContextWithCache(PWSTR buf, AppContext *local_context) { // I4262 - WCHAR tmpbuf[MAXCONTEXT], contextExDeadkeys[MAXCONTEXT]; - local_context->Get(tmpbuf, MAXCONTEXT-1); - - int n = 0; - PWSTR p = tmpbuf, q = contextExDeadkeys, r = buf; // I4266 - while(*p) { - if(*p == UC_SENTINEL) { - p += 2; // We know the only UC_SENTINEL CODE in the context is CODE_DEADKEY, which has only 1 parameter: UC_SENTINEL CODE_DEADKEY - n++; - } else { - *q++ = *p; - } - p++; - } - *q = 0; - - if(n > 0 && wcslen(buf) > wcslen(contextExDeadkeys)) { // I4266 - r += wcslen(buf) - wcslen(contextExDeadkeys); - } - - // We have to cut off the context comparison from the left by #deadkeys matched to ensure we are comparing like with like, - // at least when tmpbuf len=MAXCONTEXT-1 at entry. - -#ifdef DEBUG_MERGECONTEXT - char *mc1 = debugstr(buf), *mc2 = debugstr(contextExDeadkeys), *mc3 = debugstr(tmpbuf); - - SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::MergeContextWithCache TIP:'%s' Context:'%s' DKContext:'%s'", - mc1, mc2, mc3); - - delete mc1; - delete mc2; - delete mc3; -#endif - - if(wcscmp(r, contextExDeadkeys) != 0) { - // context has changed, reset context -#ifdef DEBUG_MERGECONTEXT - SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::MergeContextWithCache --> load context from app (losing deadkeys)"); -#endif - local_context->Set(buf); - } else { -#ifdef DEBUG_MERGECONTEXT - SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::MergeContextWithCache --> loading cached context"); -#endif - wcscpy_s(buf, MAXCONTEXT, tmpbuf); +BOOL AITIP::ReadContext(PWSTR buf) { + if (buf == nullptr) { + return FALSE; } -} -void AITIP::ReadContext() { - WCHAR buf[MAXCONTEXT]; PKEYMAN64THREADDATA _td = ThreadGlobals(); - if(!_td) return; + if(!_td) return FALSE; if(_td->TIPGetContext && (*_td->TIPGetContext)(MAXCONTEXT-1, buf) == S_OK) { // I3575 // I4262 if(ShouldDebug(sdmKeyboard)) { SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::ReadContext: full context [Updateable=%d] %s", _td->TIPFUpdateable, Debug_UnicodeString(buf)); } useLegacy = FALSE; // I3575 - - // If the text content of the context is identical, inject the deadkeys - // Otherwise, reset the cachedContext to match buf, no deadkeys - - MergeContextWithCache(buf, context); - - if(ShouldDebug(sdmKeyboard)) { - SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::ReadContext: after merge [Updateable=%d] %s", _td->TIPFUpdateable, Debug_UnicodeString(buf)); - } - - context->Set(buf); + return TRUE; } else { SendDebugMessageFormat(0, sdmAIDefault, 0, "AITIP::ReadContext: transitory context, so use buffered context [Updateable=%d]", _td->TIPFUpdateable); useLegacy = TRUE; // I3575 + return FALSE; } } -void AITIP::CopyContext(AppContext *savedContext) { - savedContext->CopyFrom(context); -} - -void AITIP::RestoreContextOnly(AppContext *savedContext) { - context->CopyFrom(savedContext); -} /* Output actions */ diff --git a/windows/src/engine/keyman32/appint/aiTIP.h b/windows/src/engine/keyman32/appint/aiTIP.h index 93c9acd4ab5..bfbca87d89e 100644 --- a/windows/src/engine/keyman32/appint/aiTIP.h +++ b/windows/src/engine/keyman32/appint/aiTIP.h @@ -37,9 +37,6 @@ class AITIP : public AIWin2000Unicode { -private: - void MergeContextWithCache(PWSTR buf, AppContext *context); // I4262 - private: BOOL useLegacy; @@ -50,20 +47,6 @@ class AITIP : public AIWin2000Unicode AITIP(); ~AITIP(); - /** - * Copy the member context - * - * @param[out] savedContext the copied context - */ - void CopyContext(AppContext *savedContext); - - /** - * Restore the passed context to the member context - * - * @param savedContext the context to restore - */ - void RestoreContextOnly(AppContext *savedContext); - /* Information functions */ virtual BOOL CanHandleWindow(HWND ahwnd); @@ -71,7 +54,12 @@ class AITIP : public AIWin2000Unicode /* Context functions */ - virtual void ReadContext(); + /** + * Reads the current application context upto MAXCONTEXT length into the supplied buffer. + * @param buf The data buffer to copy current application context into, must + * be MAXCONTEXT WCHARs or larger. + */ + virtual BOOL ReadContext(PWSTR buf); /* Queue and sending functions */ diff --git a/windows/src/engine/keyman32/appint/aiWin2000Unicode.cpp b/windows/src/engine/keyman32/appint/aiWin2000Unicode.cpp index a6b540fd47a..e8562bea35d 100644 --- a/windows/src/engine/keyman32/appint/aiWin2000Unicode.cpp +++ b/windows/src/engine/keyman32/appint/aiWin2000Unicode.cpp @@ -43,16 +43,9 @@ #include "pch.h" // I4128 // I4287 #include "serialkeyeventclient.h" - -AIWin2000Unicode::AIWin2000Unicode() -{ - context = new AppContext; -} - -AIWin2000Unicode::~AIWin2000Unicode() -{ - delete context; +AIWin2000Unicode::AIWin2000Unicode() { } +AIWin2000Unicode::~AIWin2000Unicode(){} /* Information functions */ @@ -68,7 +61,7 @@ BOOL AIWin2000Unicode::HandleWindow(HWND ahwnd) if(hwnd != ahwnd) { hwnd = ahwnd; - context->Reset(); + ResetContext(); } return TRUE; } @@ -87,33 +80,20 @@ BOOL AIWin2000Unicode::IsUnicode() /* Context functions */ -void AIWin2000Unicode::ReadContext() -{ -} - -void AIWin2000Unicode::AddContext(WCHAR ch) //I2436 -{ - context->Add(ch); +BOOL AIWin2000Unicode::ReadContext(PWSTR buf) { + UNREFERENCED_PARAMETER(buf); + // We cannot read any context from legacy apps, so we return a + // failure here -- telling Core to maintain its own cached + // context. + return FALSE; } void AIWin2000Unicode::ResetContext() { - context->Reset(); -} - -WCHAR *AIWin2000Unicode::ContextBuf(int n) -{ - return context->Buf(n); -} - -WCHAR *AIWin2000Unicode::ContextBufMax(int n) -{ - return context->BufMax(n); -} - -void AIWin2000Unicode::SetContext(const WCHAR* buf) -{ - return context->Set(buf); + PKEYMAN64THREADDATA _td = ThreadGlobals(); + if (_td && _td->lpActiveKeyboard && _td->lpActiveKeyboard->lpCoreKeyboardState) { + km_core_state_context_clear(_td->lpActiveKeyboard->lpCoreKeyboardState); + } } BYTE SavedKbdState[256]; @@ -126,40 +106,6 @@ BOOL AIWin2000Unicode::SendActions() // I4196 return PostKeys(); } -BOOL AIWin2000Unicode::QueueAction(int ItemType, DWORD dwData) -{ - int result = AppIntegration::QueueAction(ItemType, dwData); - - //SendDebugMessageFormat(hwnd, sdmAIDefault, 0, "App::QueueAction ItemType=%d dwData=%x", ItemType, dwData); - - switch(ItemType) - { - case QIT_VKEYDOWN: - break; - - case QIT_DEADKEY: - context->Add(UC_SENTINEL); - context->Add(CODE_DEADKEY); - context->Add((WORD) dwData); - break; - - case QIT_CHAR: - context->Add((WORD) dwData); - break; - - case QIT_BACK: - if(dwData & BK_BACKSPACE) - while(context->CharIsDeadkey()) context->Delete(); - //if(dwData == CODE_DEADKEY) break; - context->Delete(); - if(dwData & BK_BACKSPACE) - while(context->CharIsDeadkey()) context->Delete(); - break; - } - - return result; -} - // I1512 - SendInput with VK_PACKET for greater robustness BOOL AIWin2000Unicode::PostKeys() diff --git a/windows/src/engine/keyman32/appint/aiWin2000Unicode.h b/windows/src/engine/keyman32/appint/aiWin2000Unicode.h index 1ee49ed001f..523832742f6 100644 --- a/windows/src/engine/keyman32/appint/aiWin2000Unicode.h +++ b/windows/src/engine/keyman32/appint/aiWin2000Unicode.h @@ -32,15 +32,9 @@ class AIWin2000Unicode:public AppIntegration BOOL PostKeys(); - -protected: - AppContext *context; - public: - AIWin2000Unicode(); - ~AIWin2000Unicode(); - - virtual BOOL QueueAction(int ItemType, DWORD dwData); + AIWin2000Unicode(); + ~AIWin2000Unicode(); /* Information functions */ @@ -51,13 +45,9 @@ class AIWin2000Unicode:public AppIntegration /* Context functions */ - virtual void ReadContext(); + virtual BOOL ReadContext(PWSTR buf); virtual void ResetContext(); - virtual void AddContext(WCHAR ch); //I2436 - virtual WCHAR *ContextBuf(int n); - virtual WCHAR *ContextBufMax(int n); - virtual void SetContext(const WCHAR* buf); - + /* Queue and sending functions */ virtual BOOL SendActions(); // I4196 diff --git a/windows/src/engine/keyman32/appint/appint.cpp b/windows/src/engine/keyman32/appint/appint.cpp index 8a2b92e5aa3..13be8efda4f 100644 --- a/windows/src/engine/keyman32/appint/appint.cpp +++ b/windows/src/engine/keyman32/appint/appint.cpp @@ -32,161 +32,6 @@ const LPSTR ItemTypes[8] = { "QIT_VKEYDOWN", "QIT_VKEYUP", "QIT_VSHIFTDOWN", "QIT_VSHIFTUP", "QIT_CHAR", "QIT_DEADKEY", "QIT_BELL", "QIT_BACK" }; -/* AppContext */ - -AppContext::AppContext() -{ - Reset(); -} - -void AppContext::Add(WCHAR ch) -{ - if(pos == MAXCONTEXT - 1) { -// SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: MAXCONTEXT[%d]: %ws", pos, CurContext); - auto p = incxstr(CurContext); - auto n = p - CurContext; - memmove(CurContext, p, (MAXCONTEXT - n) * 2); - pos -= (int)n; - } - - CurContext[pos++] = ch; - CurContext[pos] = 0; - - SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: Add(%x) [%d]: %s", ch, pos, Debug_UnicodeString(CurContext)); -} - -WCHAR *AppContext::Buf(int n) -{ - WCHAR *p; - - //SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext::Buf(%d)", n); - //if(n == 0) return wcschr(CurContext, 0); - //if(*CurContext == 0) return NULL; - - for(p = wcschr(CurContext, 0); p != NULL && n > 0 && p > CurContext; p = decxstr(p, CurContext), n--); - //for(p = wcschr(CurContext, 0); n > 0 && p > CurContext; p--, n--); - - if(n > 0) return NULL; - return p; -} - -WCHAR *AppContext::BufMax(int n) -{ - WCHAR *p = wcschr(CurContext, 0); // I3091 - - if(CurContext == p || n == 0) return p; /* empty context or 0 characters requested, return pointer to end of context */ // I3091 - - WCHAR *q = p; // I3091 - for(; p != NULL && p > CurContext && (INT_PTR)(q-p) < n; p = decxstr(p, CurContext)); // I3091 - - if((INT_PTR)(q-p) > n) p = incxstr(p); /* Copes with deadkey or supplementary pair at start of returned buffer making it too long */ // I3091 - - return p; // I3091 -} - -void AppContext::Delete() -{ - if (CharIsDeadkey()) { - pos -= 2; - } else if (CharIsSurrogatePair()) { - pos--; - } - //SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext::Delete"); - - if(pos > 0) pos--; - CurContext[pos] = 0; - //if(--pos < 0) pos = 0; - //SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: Delete"); -} - -void AppContext::Reset() -{ - pos = 0; - CurContext[0] = 0; - -// SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext: Reset"); -} - -void AppContext::Get(WCHAR *buf, int bufsize) -{ - // surrogate pairs need to be treated as a single unit, therefore use - // BufMax to find a start index. - // BufMax handles the case where a surrogate pair at the - // start of the buffer is split by bufsize - for (WCHAR *p = this->BufMax(bufsize); *p && bufsize > 0; p++, bufsize--) - { - *buf = *p; - if(Uni_IsSurrogate1(*p) && bufsize - 2 > 0) { - buf++; p++; - *buf = *p; - bufsize--; - } - buf++; - } - - *buf = 0; -} - -void AppContext::CopyFrom(AppContext *source) // I3575 -{ - SendDebugMessageFormat(0, sdmAIDefault, 0, "AppContext::CopyFrom source=%s; before copy, dest=%s", Debug_UnicodeString(source->CurContext, 0), Debug_UnicodeString(CurContext, 0)); - wcscpy_s(CurContext, _countof(CurContext), source->CurContext); - pos = source->pos; -} - - -void AppContext::Set(const WCHAR *buf) -{ - const WCHAR *p; - WCHAR *q; - - // We may be past a buffer longer than our internal - // buffer. So we shift to make sure we capture the end - // of the string, not the start - p = wcschr(buf, 0); - q = (WCHAR *)p; - while (p != NULL && p > buf && (intptr_t)(q - p) < MAXCONTEXT - 1) { - p = decxstr((WCHAR *)p, (WCHAR *)buf); - } - - // If the first character in the buffer is a surrogate pair, - // or a deadkey, our buffer may be too long, so move to the - // next character in the buffer - if ((intptr_t)(q - p) > MAXCONTEXT - 1) { - p = incxstr((WCHAR *)p); - } - - for (q = CurContext; *p; p++, q++) { - *q = *p; - } - - *q = 0; - pos = (int)(intptr_t)(q - CurContext); - CurContext[MAXCONTEXT - 1] = 0; - -} - -BOOL AppContext::CharIsDeadkey() -{ - if(pos < 3) // code_sentinel, deadkey, #, 0 - return FALSE; - return CurContext[pos-3] == UC_SENTINEL && - CurContext[pos-2] == CODE_DEADKEY; -} - -BOOL AppContext::CharIsSurrogatePair() -{ - if (pos < 2) // low_surrogate, high_surrogate - return FALSE; - - return Uni_IsSurrogate1(CurContext[pos - 2]) && - Uni_IsSurrogate2(CurContext[pos - 1]); -} - -BOOL AppContext::IsEmpty() { - return (BOOL)(pos == 0); -} - /* AppActionQueue */ AppActionQueue::AppActionQueue() @@ -226,84 +71,3 @@ AppIntegration::AppIntegration() hwnd = NULL; FShiftFlags = 0; } - -BOOL ContextItemsFromAppContext(WCHAR const* buf, km_core_context_item** outPtr) -{ - assert(buf); - assert(outPtr); - km_core_context_item* context_items = new km_core_context_item[wcslen(buf) + 1]; - WCHAR const *p = buf; - uint8_t contextIndex = 0; - while (*p) { - if (*p == UC_SENTINEL) { - assert(*(p + 1) == CODE_DEADKEY); - // we know the only uc_sentinel code in the context is code_deadkey, which has only 1 parameter: uc_sentinel code_deadkey - // setup dead key context item - p += 2; - context_items[contextIndex++] = km_core_context_item{ KM_CORE_CT_MARKER, {0,}, {*p} }; - } else if (Uni_IsSurrogate1(*p) && Uni_IsSurrogate2(*(p + 1))) { - // handle surrogate - context_items[contextIndex++] = km_core_context_item{ KM_CORE_CT_CHAR, {0,}, {(char32_t)Uni_SurrogateToUTF32(*p, *(p + 1))} }; - p++; - } else { - context_items[contextIndex++] = km_core_context_item{ KM_CORE_CT_CHAR, {0,}, {*p} }; - } - p++; - } - // terminate the context_items array. - context_items[contextIndex] = km_core_context_item KM_CORE_CONTEXT_ITEM_END; - - *outPtr = context_items; - return true; -} - - -BOOL -ContextItemToAppContext(km_core_context_item *contextItems, PWSTR outBuf, DWORD len) { - assert(contextItems); - assert(outBuf); - - km_core_context_item *km_core_context_it = contextItems; - uint8_t contextLen = 0; - for (; km_core_context_it->type != KM_CORE_CT_END; ++km_core_context_it) { - ++contextLen; - } - - WCHAR *buf = new WCHAR[(contextLen*3)+ 1 ]; // *3 if every context item was a deadkey - uint8_t idx = 0; - km_core_context_it = contextItems; - for (; km_core_context_it->type != KM_CORE_CT_END; ++km_core_context_it) { - switch (km_core_context_it->type) { - case KM_CORE_CT_CHAR: - if (Uni_IsSMP(km_core_context_it->character)) { - buf[idx++] = static_cast Uni_UTF32ToSurrogate1(km_core_context_it->character); - buf[idx++] = static_cast Uni_UTF32ToSurrogate2(km_core_context_it->character); - } else { - buf[idx++] = (km_core_cp)km_core_context_it->character; - } - break; - case KM_CORE_CT_MARKER: - assert(km_core_context_it->marker > 0); - buf[idx++] = UC_SENTINEL; - buf[idx++] = CODE_DEADKEY; - buf[idx++] = static_cast(km_core_context_it->marker); - break; - } - } - - buf[idx] = 0; // Null terminate character array - - if (wcslen(buf) > len) { - // Truncate to length 'len' using AppContext so that the context closest to the caret is preserved - // and the truncation will not split deadkeys or surrogate pairs - // Note by using the app context class we will truncate the context to the MAXCONTEXT length if 'len' - // is greater than MAXCONTEXT - AppContext context; - context.Set(buf); - context.Get(outBuf, len); - } else { - wcscpy_s(outBuf, wcslen(buf) + 1, buf); - } - delete[] buf; - return TRUE; -} diff --git a/windows/src/engine/keyman32/appint/appint.h b/windows/src/engine/keyman32/appint/appint.h index 8048c4a8a94..ae31a1b54f1 100644 --- a/windows/src/engine/keyman32/appint/appint.h +++ b/windows/src/engine/keyman32/appint/appint.h @@ -65,103 +65,6 @@ class AppActionQueue int GetQueueSize() { return QueueSize; } }; -class AppContext -{ -private: - WCHAR CurContext[MAXCONTEXT]; //!< CurContext[0] is furthest from the caret and buffer is null terminated. - int pos; - -public: - AppContext(); - /** - * Copy "source" AppContext to this AppContext - * - * @param source AppContext to copy - */ - void CopyFrom(AppContext *source); - - /** - * Add a single code unit to the Current Context. Not necessarily a complete code point - * - * @param Code unit to add - */ - void Add(WCHAR ch); - - /** - * Removes a single code point from the end of the CurContext closest to the caret; - * i.e. it will be both code units if a surrogate pair. If it is a deadkey it will - * remove three code points: UC_SENTINEL, CODE_DEADKEY and deadkey value. - */ - void Delete(); - - /** - * Clears the CurContext and resets the position - pos - index - */ - void Reset(); - - /** - * Copies the characters in CurContext to supplied buffer. - * If bufsize is reached before the entire context was copied, the buf - * will be truncated to number of valid characters possible with null character - * termination. e.g. it will be one code unit less than bufsize if that would - * have meant splitting a surrogate pair - * @param buf The data buffer to copy current context - * @param bufsize The number of code units ie size of the WCHAR buffer - not the code points - */ - void Get(WCHAR *buf, int bufsize); - - /** - * Sets the CurContext to the supplied buf character array and updates the pos index. - * - * @param buf - */ - void Set(const WCHAR *buf); - - /** - * Returns a pointer to the character in the current context buffer which - * will have at most n valid xstring units remaining until the null terminating - * character. It will be one code unit less than bufsize if that would - * have meant splitting a surrogate pair or deadkey. - * - * @param n The maximum number of valid xstring units (not code points or code units) - * @return WCHAR* Pointer to the start postion for a buffer of maximum n xstring units - */ - WCHAR *BufMax(int n); - - /** - * Returns a pointer to the character in the current context buffer which - * will have n valid xstring units remaining until the the null terminating character. - * OR - * Returns NULL if there are less than n valid xstring units in the current context. - * Background this was historically for performance during rule evaluation, if there - * are not enough characters to compare, don't event attempt the comparison. - * - * @param n The number of valid xstring units (not code points or code units) - * @return KMX_WCHAR* Pointer to the start postion for a buffer of maximum n characters - */ - WCHAR *Buf(int n); - - /** - * Returns TRUE if the last xstring unit in the context is a deadkey - * - * @return BOOL - */ - BOOL CharIsDeadkey(); - - /** - * Returns TRUE if the last xstring unit in the CurContext is a surrogate pair. - * @return BOOL - */ - BOOL CharIsSurrogatePair(); - - /** - * Returns TRUE if the context is empty - * @return BOOL - */ - BOOL AppContext::IsEmpty(); - -}; - class AppIntegration:public AppActionQueue { protected: @@ -180,12 +83,12 @@ class AppIntegration:public AppActionQueue virtual BOOL IsUnicode() = 0; /* Context functions */ - - virtual void ReadContext() = 0; + /** + * Reads the current application context upto MAXCONTEXT length into the supplied buffer. + * @param buf The data buffer to copy current application context + */ + virtual BOOL ReadContext(PWSTR buf) = 0; virtual void ResetContext() = 0; - virtual void AddContext(WCHAR ch) = 0; //I2436 - virtual WCHAR *ContextBuf(int n) = 0; - virtual WCHAR *ContextBufMax(int n) = 0; /* Queue and sending functions */ @@ -193,30 +96,6 @@ class AppIntegration:public AppActionQueue virtual BOOL SendActions() = 0; // I4196 }; -/** - * Convert AppContext array into an array of core context items. - * Caller is responsible for freeing the memory. - * - * @param buf appcontext character array - * @param outPtr The ouput array of context items. caller to free memory - * @return BOOL True if array created successfully - */ -BOOL ContextItemsFromAppContext(WCHAR const* buf, km_core_context_item** outPtr); - -/** - * Convert km_core_context_item array into an kmx char buffer. - * Caller is responsible for freeing the memory. - * The length is restricted to a maximum of MAXCONTEXT length. If the number - * of input km_core_context_items exceeds this length the characters furthest - * from the caret will be truncated. - * - * @param contextItems the input core context array. (km_core_context_item) - * @param [out] outBuf the kmx character array output. caller to free memory. - * - * @return BOOL True if array created successfully - */ -BOOL ContextItemToAppContext(km_core_context_item *contextItems, PWSTR outBuf, DWORD len); - extern const LPSTR ItemTypes[]; #endif diff --git a/windows/src/engine/keyman32/keyman32.vcxproj b/windows/src/engine/keyman32/keyman32.vcxproj index 70b8402ad79..021aa55d65d 100644 --- a/windows/src/engine/keyman32/keyman32.vcxproj +++ b/windows/src/engine/keyman32/keyman32.vcxproj @@ -176,6 +176,7 @@ + %(AdditionalIncludeDirectories) %(PreprocessorDefinitions) @@ -354,6 +355,7 @@ + @@ -387,4 +389,4 @@ - + \ No newline at end of file diff --git a/windows/src/engine/keyman32/keyman32.vcxproj.filters b/windows/src/engine/keyman32/keyman32.vcxproj.filters index dcf5f5d4c51..0f16750b7c0 100644 --- a/windows/src/engine/keyman32/keyman32.vcxproj.filters +++ b/windows/src/engine/keyman32/keyman32.vcxproj.filters @@ -138,6 +138,9 @@ Source Files + + Source Files + @@ -246,6 +249,9 @@ Header Files + + Header Files + diff --git a/windows/src/engine/keyman32/keymanengine.h b/windows/src/engine/keyman32/keymanengine.h index 3c6e2d3affc..c0db193fdfb 100644 --- a/windows/src/engine/keyman32/keymanengine.h +++ b/windows/src/engine/keyman32/keymanengine.h @@ -126,7 +126,6 @@ BOOL IsSysTrayWindow(HWND hwnd); BOOL InitialiseProcess(HWND hwnd); BOOL UninitialiseProcess(BOOL Lock); -BOOL IsKeyboardUnicode(); BOOL IsFocusedThread(); @@ -231,6 +230,7 @@ void keybd_shift(LPINPUT pInputs, int* n, BOOL isReset, LPBYTE const kbd); #include "keymancontrol.h" #include "keyboardoptions.h" #include "kmprocessactions.h" +#include "appcontext.h" #include "syskbd.h" #include "vkscancodes.h" diff --git a/windows/src/engine/keyman32/kmprocess.cpp b/windows/src/engine/keyman32/kmprocess.cpp index 7e5e8070567..b83079b03d7 100644 --- a/windows/src/engine/keyman32/kmprocess.cpp +++ b/windows/src/engine/keyman32/kmprocess.cpp @@ -70,23 +70,29 @@ BOOL fOutputKeystroke; -/*char *getcontext() -{ - WCHAR buf[128]; - static char bufout[128]; +char *getcontext_debug() { + PKEYMAN64THREADDATA _td = ThreadGlobals(); - if(!_td) return ""; - _td->app->GetWindowContext(buf, 128); - WideCharToMultiByte(CP_ACP, 0, buf, -1, bufout, 128, NULL, NULL); - return bufout; -}*/ + if (!_td || !_td->lpActiveKeyboard || !_td->lpActiveKeyboard->lpCoreKeyboardState){ + return ""; + } + + WCHAR buf[(MAXCONTEXT * 3) + 1]; // *3 if every context item was a deadkey + km_core_context_item *citems = nullptr; + if (KM_CORE_STATUS_OK != km_core_context_get( + km_core_state_context(_td->lpActiveKeyboard->lpCoreKeyboardState), &citems)) { + return ""; + } + + DWORD context_length = (DWORD)km_core_context_item_list_size(citems); + if (!ContextItemToAppContext(citems, buf, context_length)) { + km_core_context_items_dispose(citems); + return ""; + } + km_core_context_items_dispose(citems); + return Debug_UnicodeString(buf); -char *getcontext_debug() { - //return ""; - PKEYMAN64THREADDATA _td = ThreadGlobals(); - if(!_td) return ""; - return Debug_UnicodeString(_td->app->ContextBufMax(128)); } /** @@ -98,14 +104,15 @@ char *getcontext_debug() { static BOOL Process_Event_Core(PKEYMAN64THREADDATA _td) { - PWSTR contextBuf = _td->app->ContextBufMax(MAXCONTEXT); - km_core_context_item *citems = nullptr; - ContextItemsFromAppContext(contextBuf, &citems); - if (KM_CORE_STATUS_OK != km_core_context_set(km_core_state_context(_td->lpActiveKeyboard->lpCoreKeyboardState), citems)) { - km_core_context_items_dispose(citems); - return FALSE; + WCHAR application_context[MAXCONTEXT]; + if (_td->app->ReadContext(application_context)) { + km_core_context_status result; + result = km_core_state_context_set_if_needed(_td->lpActiveKeyboard->lpCoreKeyboardState, reinterpret_cast(application_context)); + if (result == KM_CORE_CONTEXT_STATUS_ERROR || result == KM_CORE_CONTEXT_STATUS_INVALID_ARGUMENT) { + SendDebugMessageFormat(0, sdmGlobal, 0, "Process_Event_Core: km_core_state_context_set_if_needed returned [%d]", result); + } } - km_core_context_items_dispose(citems); + SendDebugMessageFormat( 0, sdmGlobal, 0, "ProcessEvent: vkey[%d] ShiftState[%d] isDown[%d]", _td->state.vkey, static_cast(Globals::get_ShiftState() & (KM_CORE_MODIFIER_MASK_ALL | KM_CORE_MODIFIER_MASK_CAPS)), (uint8_t)_td->state.isDown); @@ -139,8 +146,6 @@ BOOL ProcessHook() fOutputKeystroke = FALSE; // TODO: 5442 no longer needs to be global once we use core processor - _td->app->ReadContext(); - if(_td->state.msg.message == wm_keymankeydown) { // I4827 if (ShouldDebug(sdmKeyboard)) { SendDebugMessageFormat(_td->state.msg.hwnd, sdmKeyboard, 0, "Key pressed: %s Context '%s'", @@ -212,9 +217,6 @@ BOOL ProcessHook() _td->app->SetCurrentShiftState(Globals::get_ShiftState()); _td->app->SendActions(); // I4196 } - // output context for debugging - // PWSTR contextBuf = _td->app->ContextBufMax(MAXCONTEXT); - // SendDebugMessageFormat(0, sdmAIDefault, 0, "Kmprocess::ProcessHook After cxt=%s", Debug_UnicodeString(contextBuf, 1)); return !fOutputKeystroke; } diff --git a/windows/src/engine/keyman32/kmprocessactions.cpp b/windows/src/engine/keyman32/kmprocessactions.cpp index c9d607a596c..1eecfa6807e 100644 --- a/windows/src/engine/keyman32/kmprocessactions.cpp +++ b/windows/src/engine/keyman32/kmprocessactions.cpp @@ -78,10 +78,8 @@ static BOOL processPersistOpt( } static BOOL processInvalidateContext( - AITIP* app, - km_core_state* keyboardState + AITIP* app ) { - km_core_context_clear(km_core_state_context(keyboardState)); app->ResetContext(); return TRUE; } @@ -158,7 +156,7 @@ BOOL ProcessActions(BOOL* emitKeyStroke) continueProcessingActions = TRUE; break; case KM_CORE_IT_INVALIDATE_CONTEXT: - continueProcessingActions = processInvalidateContext(_td->app, _td->lpActiveKeyboard->lpCoreKeyboardState); + continueProcessingActions = processInvalidateContext(_td->app); break; case KM_CORE_IT_CAPSLOCK: continueProcessingActions = processCapsLock(act, !_td->state.isDown, _td->TIPFUpdateable, FALSE); @@ -202,7 +200,7 @@ ProcessActionsNonUpdatableParse(BOOL* emitKeyStroke) { continueProcessingActions = processCapsLock(act, !_td->state.isDown, _td->TIPFUpdateable, FALSE); break; case KM_CORE_IT_INVALIDATE_CONTEXT: - continueProcessingActions = processInvalidateContext(_td->app, _td->lpActiveKeyboard->lpCoreKeyboardState); + continueProcessingActions = processInvalidateContext(_td->app); break; } if (!continueProcessingActions) { @@ -228,7 +226,7 @@ ProcessActionsExternalEvent() { continueProcessingActions = processCapsLock(act, !_td->state.isDown, FALSE, TRUE); break; case KM_CORE_IT_INVALIDATE_CONTEXT: - continueProcessingActions = processInvalidateContext(_td->app, _td->lpActiveKeyboard->lpCoreKeyboardState); + continueProcessingActions = processInvalidateContext(_td->app); break; } if (!continueProcessingActions) { diff --git a/windows/src/engine/keyman64/keyman64.vcxproj b/windows/src/engine/keyman64/keyman64.vcxproj index f80c93089d7..9e9b5f7e714 100644 --- a/windows/src/engine/keyman64/keyman64.vcxproj +++ b/windows/src/engine/keyman64/keyman64.vcxproj @@ -55,11 +55,11 @@ $(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\src;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\subprojects\icu\source\common;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\subprojects\icu\source\i18n;$(ProjectDir)..\..\..\..\core\build\rust\x64\$(Configuration);$(VC_LibraryPath_x64);$(WindowsSDK_LibraryPath_x64) - $(ProjectDir)..\..\..\..\common\include;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\include;$(ProjectDir)..\..\..\..\core\include;$(IncludePath) + $(ProjectDir)..\..\..\..\common\include;$(ProjectDir)..\..\..\..\core\include;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\include;$(IncludePath) $(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\src;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\subprojects\icu\source\common;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\subprojects\icu\source\i18n;$(ProjectDir)..\..\..\..\core\build\rust\x64\$(Configuration);$(VC_LibraryPath_x64);$(WindowsSDK_LibraryPath_x64) - $(ProjectDir)..\..\..\..\common\include;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\include;$(ProjectDir)..\..\..\..\core\include;$(IncludePath) + $(ProjectDir)..\..\..\..\common\include;$(ProjectDir)..\..\..\..\core\include;$(ProjectDir)..\..\..\..\core\build\x64\$(Configuration)\include;$(IncludePath) @@ -152,7 +152,7 @@ /verbose:lib /section:.SHARDATA,rws %(AdditionalOptions) - libicuuc.a;libicuin.a;libkeymancore.a;psapi.lib;version.lib;setupapi.lib;iphlpapi.lib;imm32.lib;crypt32.lib;wintrust.lib;imagehlp.lib;ws2_32.lib;libcmt.lib;%(AdditionalDependencies) + libicuuc.a;libicuin.a;libkeymancore.a;psapi.lib;version.lib;setupapi.lib;iphlpapi.lib;imm32.lib;crypt32.lib;wintrust.lib;imagehlp.lib;ws2_32.lib;libcmtd.lib;%(AdditionalDependencies) true C:\Program Files\Microsoft SDKs\Windows\v7.0\Lib\x64;$(VCInstallDir)lib\amd64;$(VCInstallDir)lib;%(AdditionalLibraryDirectories) false @@ -177,6 +177,7 @@ + @@ -276,6 +277,7 @@ + @@ -327,4 +329,4 @@ - + \ No newline at end of file diff --git a/windows/src/engine/keyman64/keyman64.vcxproj.filters b/windows/src/engine/keyman64/keyman64.vcxproj.filters index cd8aeefb322..06ba0e951e1 100644 --- a/windows/src/engine/keyman64/keyman64.vcxproj.filters +++ b/windows/src/engine/keyman64/keyman64.vcxproj.filters @@ -38,6 +38,7 @@ + @@ -72,6 +73,7 @@ +