mirror of
https://github.com/Helium314/HeliBoard.git
synced 2025-04-18 21:32:04 +00:00
Make layouts customizable (#361)
now layouts can be added from files, or default layouts can be copied and edited
This commit is contained in:
parent
450338c080
commit
54abf44628
21 changed files with 522 additions and 129 deletions
|
@ -22,6 +22,7 @@ Might end up on F-Droid...
|
|||
* Number pad
|
||||
* Show all available extra characters on long pressing a key
|
||||
* Backup your learned word / history data
|
||||
* Add custom keyboard layouts
|
||||
* Adjustable bottom padding
|
||||
|
||||
## Hidden functionality
|
||||
|
|
|
@ -438,4 +438,13 @@ public final class KeyboardLayoutSet {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// used for testing keyboard layout files without actually creating a keyboard
|
||||
public static KeyboardId getFakeKeyboardId(final int elementId) {
|
||||
final Params params = new Params();
|
||||
params.mEditorInfo = new EditorInfo();
|
||||
params.mSubtype = RichInputMethodSubtype.getEmojiSubtype();
|
||||
params.mSubtype.getKeyboardLayoutSetName();
|
||||
return new KeyboardId(elementId, params);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -300,7 +300,12 @@ public final class MoreKeysKeyboard extends Keyboard {
|
|||
dividerWidth = 0;
|
||||
}
|
||||
final MoreKeySpec[] moreKeys = key.getMoreKeys();
|
||||
mParams.setParameters(moreKeys.length, key.getMoreKeysColumnNumber(), keyWidth,
|
||||
final int defaultColumns = key.getMoreKeysColumnNumber();
|
||||
final int spaceForKeys = keyboard.mId.mWidth / keyWidth;
|
||||
final int finalNumColumns = spaceForKeys >= Math.min(moreKeys.length, defaultColumns)
|
||||
? defaultColumns
|
||||
: (spaceForKeys > 0 ? spaceForKeys : defaultColumns); // in last case setParameters will throw an exception
|
||||
mParams.setParameters(moreKeys.length, finalNumColumns, keyWidth,
|
||||
rowHeight, key.getX() + key.getWidth() / 2, keyboard.mId.mWidth,
|
||||
key.isMoreKeysFixedColumn(), key.isMoreKeysFixedOrder(), dividerWidth);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import androidx.appcompat.widget.AppCompatTextView;
|
|||
|
||||
import org.dslul.openboard.inputmethod.keyboard.Key;
|
||||
import org.dslul.openboard.inputmethod.latin.R;
|
||||
import org.dslul.openboard.inputmethod.latin.common.StringUtilsKt;
|
||||
|
||||
import java.util.HashSet;
|
||||
|
||||
|
@ -66,6 +67,10 @@ public class KeyPreviewView extends AppCompatTextView {
|
|||
if (sNoScaleXTextSet.contains(text)) {
|
||||
return;
|
||||
}
|
||||
if (StringUtilsKt.isEmoji(text)) {
|
||||
sNoScaleXTextSet.add(text);
|
||||
return;
|
||||
}
|
||||
// TODO: Override {@link #setBackground(Drawable)} that is supported from API 16 and
|
||||
// calculate maximum text width.
|
||||
final Drawable background = getBackground();
|
||||
|
|
|
@ -8,6 +8,7 @@ package org.dslul.openboard.inputmethod.keyboard.internal;
|
|||
|
||||
import org.dslul.openboard.inputmethod.latin.common.Constants;
|
||||
import org.dslul.openboard.inputmethod.latin.common.StringUtils;
|
||||
import org.dslul.openboard.inputmethod.latin.define.DebugFlags;
|
||||
|
||||
import static org.dslul.openboard.inputmethod.latin.common.Constants.CODE_OUTPUT_TEXT;
|
||||
import static org.dslul.openboard.inputmethod.latin.common.Constants.CODE_UNSPECIFIED;
|
||||
|
@ -88,7 +89,9 @@ public final class KeySpecParser {
|
|||
// Treat a sole vertical bar as a special case of key label.
|
||||
return -1;
|
||||
}
|
||||
throw new KeySpecParserError("Empty label");
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
throw new KeySpecParserError("Empty label");
|
||||
else return -1;
|
||||
}
|
||||
return labelEnd;
|
||||
}
|
||||
|
@ -118,7 +121,8 @@ public final class KeySpecParser {
|
|||
if (indexOfLabelEnd(getAfterLabelEnd(keySpec, labelEnd)) < 0) {
|
||||
return;
|
||||
}
|
||||
throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec);
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
@ -132,7 +136,7 @@ public final class KeySpecParser {
|
|||
}
|
||||
final int labelEnd = indexOfLabelEnd(keySpec);
|
||||
final String label = parseEscape(getBeforeLabelEnd(keySpec, labelEnd));
|
||||
if (label.isEmpty()) {
|
||||
if (label.isEmpty() && DebugFlags.DEBUG_ENABLED) {
|
||||
throw new KeySpecParserError("Empty label: " + keySpec);
|
||||
}
|
||||
return label;
|
||||
|
@ -164,13 +168,13 @@ public final class KeySpecParser {
|
|||
// See {@link #getCode(Resources, String)}.
|
||||
return null;
|
||||
}
|
||||
if (outputText.isEmpty()) {
|
||||
if (outputText.isEmpty() && DebugFlags.DEBUG_ENABLED) {
|
||||
throw new KeySpecParserError("Empty outputText: " + keySpec);
|
||||
}
|
||||
return outputText;
|
||||
}
|
||||
final String label = getLabel(keySpec);
|
||||
if (label == null) {
|
||||
if (label == null && DebugFlags.DEBUG_ENABLED) {
|
||||
throw new KeySpecParserError("Empty label: " + keySpec);
|
||||
}
|
||||
// Code is automatically generated for one letter label. See {@link getCode()}.
|
||||
|
@ -198,7 +202,9 @@ public final class KeySpecParser {
|
|||
}
|
||||
final String label = getLabel(keySpec);
|
||||
if (label == null) {
|
||||
throw new KeySpecParserError("Empty label: " + keySpec);
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
throw new KeySpecParserError("Empty label: " + keySpec);
|
||||
else return CODE_OUTPUT_TEXT;
|
||||
}
|
||||
// Code is automatically generated for one letter label.
|
||||
return (StringUtils.codePointCount(label) == 1) ? label.codePointAt(0) : CODE_OUTPUT_TEXT;
|
||||
|
|
|
@ -47,66 +47,6 @@ open class KeyboardBuilder<KP : KeyboardParams>(protected val mContext: Context,
|
|||
mParams.mAllowRedundantMoreKeys = enabled
|
||||
}
|
||||
|
||||
// todo: further plan
|
||||
// after the old parser is removed
|
||||
// maybe the language -> layout thing could be moved to assets? and maybe even here the extra keys could be defined...
|
||||
// should be either both in method.xml, or both in assets (actually method might be more suitable)
|
||||
// go through a lot of todos in parsers, key, keyboardlayoutset, ... as a lot of things should only change after old parser is removed
|
||||
// also remove the keyboard_layout_set files?
|
||||
// allow users to define their own layouts (maybe do everything else first?)
|
||||
// need to solve the scaling issue with number row and 5 row keyboards
|
||||
// write up how things work for users, also regarding language more keys
|
||||
// readme, maybe also some "help" button in a dialog
|
||||
// some sort of proper UI, or simply text input?
|
||||
// better text import for the start because of much work
|
||||
// ui follows later (consider that users need to be able to start from existing layouts!)
|
||||
// some warning if more than 2 or 3 characters on a single label
|
||||
// currently can't resize keys, but could set autoXScale (does only decrease size, never increase)
|
||||
// careful about moreKeys: if moreKeys don't fit on screen, parser throws an exception!
|
||||
// need to somehow test for this
|
||||
// is that autoColumnOrder thing a workaround for that?
|
||||
// still would crash for a single huge label
|
||||
// potential keyspec parsing issues:
|
||||
// MoreKeySpec constructor does things like KeySpecParser.getLabel and others
|
||||
// these work with special characters like | and \ doing things depending on their position
|
||||
// if used wrongly, things can crash
|
||||
// -> maybe disable this style of parsing when creating MoreKeySpec of a user-provided layout
|
||||
// or also for the simple layouts, because there is no need to have it in layouts
|
||||
// does the same issue apply to normal key labels?
|
||||
// popup and (single key) long press preview rescale the label on x only, which may deform emojis
|
||||
// does glide typing work with multiple letters on one key? if not, users should be notified
|
||||
// maybe allow users to define their own symbol and shift-symbol layouts
|
||||
// allow users to import layouts, which essentially just fills the text from a file
|
||||
|
||||
// labelFlags should be set correctly
|
||||
// alignHintLabelToBottom: on lxx and rounded themes, but did not find what it actually does...
|
||||
// alignIconToBottom: space_key_for_number_layout
|
||||
// alignLabelOffCenter: number keys in phone layout
|
||||
// fontNormal: turkish (rows 1 and 2 only), .com, emojis, numModeKeyStyle, a bunch of non-latin languages
|
||||
// -> switches to normal typeface, only relevant for holo which has bold
|
||||
// fontMonoSpace: unused
|
||||
// fontDefault: keyExclamationQuestion, a bunch of "normal" keys in fontNormal layouts like thai
|
||||
// -> switches to default defined typeface, useful e.g. if row has fontNormal
|
||||
// followKeyLargeLetterRatio: number keys in number/phone/numpad layouts
|
||||
// followKeyLetterRatio: mode keys in number layouts, some keys in some non-latin layouts
|
||||
// followKeyLabelRatio: enter key, some keys in phone layout (same as followKeyLetterRatio + followKeyLargeLetterRatio)
|
||||
// followKeyHintLabelRatio: unused directly (but includes some others)
|
||||
// hasPopupHint: basically the long-pressable functional keys
|
||||
// hasShiftedLetterHint: period key and some keys on pcqwerty
|
||||
// hasHintLabel: number keys in number layouts
|
||||
// autoXScale: com key, action keys, some on phone layout, some non-latin languages
|
||||
// autoScale: only one single letter in khmer layout (includes autoXScale)
|
||||
// preserveCase: action key + more keys, com key, shift keys
|
||||
// shiftedLetterActivated: period and some keys on pcqwerty, tablet only (wtf, when enabled can't open moreKeys -> remove? or what would be the use?)
|
||||
// fromCustomActionLabel: action key with customLabelActionKeyStyle -> check parser where to get this info
|
||||
// followFunctionalTextColor: number mode keys, action key
|
||||
// keepBackgroundAspectRatio: lxx and rounded action more keys, lxx no-border action and emoji, moreKeys keyboard view
|
||||
// disableKeyHintLabel: keys in pcqwerty row 1 and number row
|
||||
// disableAdditionalMoreKeys: only keys in pcqwerty row 1 so there is no number row -> not necessary with the new layouts, just remove it completely
|
||||
// maybe remove some of the flags? or keep supporting them?
|
||||
// for pcqwerty: hasShiftedLetterHint -> hasShiftedLetterHint|shiftedLetterActivated when shift is enabled, need to consider if the flag is used
|
||||
// actually period key also has shifted letter hint
|
||||
|
||||
fun load(xmlId: Int, id: KeyboardId): KeyboardBuilder<KP> {
|
||||
mParams.mId = id
|
||||
if (id.isEmojiKeyboard) {
|
||||
|
@ -120,7 +60,10 @@ open class KeyboardBuilder<KP : KeyboardParams>(protected val mContext: Context,
|
|||
mParams.mMoreKeyTypes.addAll(sv.mMoreKeyTypes)
|
||||
// add label source only if moreKey type enabled
|
||||
sv.mMoreKeyLabelSources.forEach { if (it in sv.mMoreKeyTypes) mParams.mMoreKeyLabelSources.add(it) }
|
||||
keysInRows = KeyboardParser.parseFromAssets(mParams, mContext)
|
||||
keysInRows = if (mParams.mId.isAlphabetKeyboard && mParams.mId.mSubtype.isCustom)
|
||||
KeyboardParser.parseCustom(mParams, mContext)
|
||||
else
|
||||
KeyboardParser.parseFromAssets(mParams, mContext)
|
||||
determineAbsoluteValues()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "error parsing layout $id ${id.mElementId}", e)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
package org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser
|
||||
|
||||
import android.content.Context
|
||||
|
@ -6,7 +7,6 @@ import android.content.res.Resources
|
|||
import android.os.Build
|
||||
import org.dslul.openboard.inputmethod.latin.utils.Log
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import org.dslul.openboard.inputmethod.keyboard.Key
|
||||
import org.dslul.openboard.inputmethod.keyboard.Key.KeyParams
|
||||
|
@ -19,16 +19,19 @@ import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.floris.
|
|||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.floris.SimplePopups
|
||||
import org.dslul.openboard.inputmethod.latin.R
|
||||
import org.dslul.openboard.inputmethod.latin.common.Constants
|
||||
import org.dslul.openboard.inputmethod.latin.common.isEmoji
|
||||
import org.dslul.openboard.inputmethod.latin.common.splitOnWhitespace
|
||||
import org.dslul.openboard.inputmethod.latin.define.DebugFlags
|
||||
import org.dslul.openboard.inputmethod.latin.settings.Settings
|
||||
import org.dslul.openboard.inputmethod.latin.spellcheck.AndroidSpellCheckerService
|
||||
import org.dslul.openboard.inputmethod.latin.utils.CUSTOM_LAYOUT_PREFIX
|
||||
import org.dslul.openboard.inputmethod.latin.utils.InputTypeUtils
|
||||
import org.dslul.openboard.inputmethod.latin.utils.MORE_KEYS_LAYOUT
|
||||
import org.dslul.openboard.inputmethod.latin.utils.MORE_KEYS_NUMBER
|
||||
import org.dslul.openboard.inputmethod.latin.utils.RunInLocale
|
||||
import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils
|
||||
import org.dslul.openboard.inputmethod.latin.utils.sumOf
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
|
@ -111,10 +114,8 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
// setting the correct moreKeys is handled in PopupSet
|
||||
// not for korean/lao/thai layouts, todo: should be decided in the layout / layoutInfos, not in the parser
|
||||
baseKeys.first().take(10).forEachIndexed { index, keyData -> keyData.popup.numberIndex = index }
|
||||
if (DebugFlags.DEBUG_ENABLED && baseKeys.first().size < 10) {
|
||||
val message = "first row only has ${baseKeys.first().size} keys: ${baseKeys.first().map { it.label }}"
|
||||
Log.w(TAG, message)
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
if (baseKeys.first().size < 10) {
|
||||
Log.w(TAG, "first row only has ${baseKeys.first().size} keys: ${baseKeys.first().map { it.label }}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,9 +189,10 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
}
|
||||
|
||||
for (key in row) {
|
||||
// todo: maybe autoScale / autoXScale if label has more than 2 characters (exception for emojis?)
|
||||
// but that could also be determined in toKeyParams
|
||||
val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags)
|
||||
val extraFlags = if (key.label.length > 2 && key.label.codePointCount(0, key.label.length) > 2 && !isEmoji(key.label))
|
||||
Key.LABEL_FLAGS_AUTO_X_SCALE
|
||||
else 0
|
||||
val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags or extraFlags)
|
||||
paramsRow.add(keyParams)
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
Log.d(TAG, "adding key ${keyParams.mLabel}, ${keyParams.mCode}")
|
||||
|
@ -239,6 +241,8 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
val rowAboveLastNormalRowKeyWidth = rowAboveLastNormalRow.first { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL }.mRelativeWidth
|
||||
if (lastNormalRowKeyWidth <= rowAboveLastNormalRowKeyWidth + 0.0001f)
|
||||
return // no need
|
||||
if (lastNormalRowKeyWidth / rowAboveLastNormalRowKeyWidth > 1.1f)
|
||||
return // don't resize on large size difference
|
||||
if (lastNormalRow.any { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL && it.mRelativeWidth != lastNormalRowKeyWidth })
|
||||
return // normal keys have different width, don't deal with this
|
||||
val numberOfNormalKeys = lastNormalRow.count { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL }
|
||||
|
@ -667,10 +671,7 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
return this
|
||||
val id = context.resources.getIdentifier("label_$this", "string", context.packageName)
|
||||
if (id == 0) {
|
||||
val message = "no resource for label $this in ${params.mId}"
|
||||
Log.w(TAG, message)
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
Log.w(TAG, "no resource for label $this in ${params.mId}")
|
||||
return this
|
||||
}
|
||||
return getInLocale(id)
|
||||
|
@ -782,6 +783,15 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
companion object {
|
||||
private val TAG = KeyboardParser::class.simpleName
|
||||
|
||||
fun parseCustom(params: KeyboardParams, context: Context): ArrayList<ArrayList<KeyParams>> {
|
||||
val layoutName = params.mId.mSubtype.keyboardLayoutSetName
|
||||
val f = File(context.filesDir, "layouts${File.separator}$layoutName")
|
||||
return if (layoutName.endsWith(".json"))
|
||||
JsonKeyboardParser(params, context).parseLayoutString(f.readText())
|
||||
else
|
||||
SimpleKeyboardParser(params, context).parseLayoutString(f.readText())
|
||||
}
|
||||
|
||||
fun parseFromAssets(params: KeyboardParams, context: Context): ArrayList<ArrayList<KeyParams>> {
|
||||
val id = params.mId
|
||||
val layoutName = params.mId.mSubtype.keyboardLayoutSetName.substringBefore("+")
|
||||
|
@ -812,21 +822,20 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
// touchPositionCorrectionData needs to be loaded, currently always holo is applied in readAttributes
|
||||
private fun layoutInfos(params: KeyboardParams): LayoutInfos {
|
||||
val name = params.mId.mSubtype.keyboardLayoutSetName
|
||||
val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (name) {
|
||||
"armenian_phonetic", "arabic", "arabic_pc", "bengali", "bengali_akkhor", "bengali_unijoy",
|
||||
"farsi", "hindi", "hindi_compact", "lao", "marathi", "nepali_romanized", "nepali_traditional",
|
||||
"thai", "urdu" -> Key.LABEL_FLAGS_FONT_NORMAL
|
||||
"kannada", "khmer", "malayalam", "sinhala", "tamil", "telugu" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE
|
||||
val language = params.mId.locale.language
|
||||
val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (language) {
|
||||
"hy", "ar", "be", "fa", "hi", "lo", "mr", "ne", "th", "ur" -> Key.LABEL_FLAGS_FONT_NORMAL
|
||||
"kn", "km", "ml", "si", "ta", "te" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE
|
||||
else -> 0
|
||||
}
|
||||
// only for alphabet, but some exceptions for shift layouts
|
||||
val enableProximityCharsCorrection = params.mId.isAlphabetKeyboard && when (name) {
|
||||
// todo: test effect on correction (just add qwerty to the list for testing)
|
||||
"akkhor", "georgian", "hindi", "lao", "nepali_romanized", "nepali_traditional", "sinhala", "thai" ->
|
||||
"bengali_akkhor", "georgian", "hindi", "lao", "nepali_romanized", "nepali_traditional", "sinhala", "thai" ->
|
||||
params.mId.mElementId == KeyboardId.ELEMENT_ALPHABET
|
||||
else -> true
|
||||
}
|
||||
val allowRedundantMoreKeys = name != "nordic" && name != "serbian_qwertz" && params.mId.mElementId != KeyboardId.ELEMENT_SYMBOLS
|
||||
val allowRedundantMoreKeys = params.mId.mElementId != KeyboardId.ELEMENT_SYMBOLS // todo: always set to false?
|
||||
// essentially this is default for 4 row and non-alphabet layouts, maybe this could be determined automatically instead of using a list
|
||||
// todo: check the difference between default (i.e. none) and holo (test behavior on keyboard)
|
||||
// todo: null for MoreKeysKeyboard only
|
||||
|
@ -834,6 +843,7 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
|
|||
R.array.touch_position_correction_data_default
|
||||
else R.array.touch_position_correction_data_holo
|
||||
val hasZwnjKey = params.mId.locale.language in listOf("fa", "ne", "kn", "te") // determine from language, user might have custom layout
|
||||
// custom non-json layout for non-uppercase language should not have shift key
|
||||
val hasShiftKey = !params.mId.isAlphabetKeyboard || name !in listOf("hindi_compact", "bengali", "arabic", "arabic_pc", "hebrew", "kannada", "malayalam", "marathi", "farsi", "tamil", "telugu")
|
||||
return LayoutInfos(labelFlags, enableProximityCharsCorrection, allowRedundantMoreKeys, touchPositionCorrectionData, hasZwnjKey, hasShiftKey)
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ class SimpleKeyboardParser(private val params: KeyboardParams, private val conte
|
|||
context.assets.open("layouts/$layoutName.txt").reader().readText()
|
||||
|
||||
override fun parseCoreLayout(layoutContent: String): MutableList<List<KeyData>> {
|
||||
val rowStrings = layoutContent.replace("\r\n", "\n").split("\n\n")
|
||||
val rowStrings = layoutContent.replace("\r\n", "\n").split("\\n\\s*\\n".toRegex())
|
||||
return rowStrings.mapIndexedNotNullTo(mutableListOf()) { i, row ->
|
||||
if (row.isBlank()) return@mapIndexedNotNullTo null
|
||||
if (addExtraKeys)
|
||||
|
|
|
@ -7,12 +7,13 @@
|
|||
package org.dslul.openboard.inputmethod.latin;
|
||||
|
||||
import android.os.Build;
|
||||
import org.dslul.openboard.inputmethod.latin.utils.Log;
|
||||
import android.view.inputmethod.InputMethodSubtype;
|
||||
|
||||
import org.dslul.openboard.inputmethod.compat.InputMethodSubtypeCompatUtils;
|
||||
import org.dslul.openboard.inputmethod.latin.common.Constants;
|
||||
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
|
||||
import org.dslul.openboard.inputmethod.latin.utils.CustomLayoutUtilsKt;
|
||||
import org.dslul.openboard.inputmethod.latin.utils.Log;
|
||||
import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
@ -72,6 +73,10 @@ public class RichInputMethodSubtype {
|
|||
return SubtypeLocaleUtils.NO_LANGUAGE.equals(mSubtype.getLocale());
|
||||
}
|
||||
|
||||
public boolean isCustom() {
|
||||
return getKeyboardLayoutSetName().startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX);
|
||||
}
|
||||
|
||||
public String getNameForLogging() {
|
||||
return toString();
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -53,6 +53,7 @@ class AdvancedSettingsFragment : SubScreenFragment() {
|
|||
private var libfile: File? = null
|
||||
private val backupFilePatterns by lazy { listOf(
|
||||
"blacklists/.*\\.txt".toRegex(),
|
||||
"layouts/.*.(txt|json)".toRegex(),
|
||||
"dicts/.*/.*user\\.dict".toRegex(),
|
||||
"userunigram.*/userunigram.*\\.(body|header)".toRegex(),
|
||||
"UserHistoryDictionary.*/UserHistoryDictionary.*\\.(body|header)".toRegex(),
|
||||
|
|
|
@ -4,10 +4,12 @@ package org.dslul.openboard.inputmethod.latin.settings
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.InputType
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
|
@ -17,10 +19,13 @@ import androidx.core.view.get
|
|||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.size
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher
|
||||
import org.dslul.openboard.inputmethod.latin.BinaryDictionaryGetter
|
||||
import org.dslul.openboard.inputmethod.latin.R
|
||||
import org.dslul.openboard.inputmethod.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET
|
||||
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils
|
||||
import org.dslul.openboard.inputmethod.latin.databinding.LanguageListItemBinding
|
||||
import org.dslul.openboard.inputmethod.latin.databinding.LocaleSettingsDialogBinding
|
||||
|
@ -69,7 +74,7 @@ class LanguageSettingsDialog(
|
|||
}
|
||||
|
||||
private fun fillSubtypesView() {
|
||||
if (infos.any { it.subtype.isAsciiCapable }) { // currently can only add subtypes for latin keyboards
|
||||
if (infos.first().subtype.isAsciiCapable) {
|
||||
binding.addSubtype.setOnClickListener {
|
||||
val layouts = context.resources.getStringArray(R.array.predefined_layouts)
|
||||
.filterNot { layoutName -> infos.any { SubtypeLocaleUtils.getKeyboardLayoutSetName(it.subtype) == layoutName } }
|
||||
|
@ -78,19 +83,14 @@ class LanguageSettingsDialog(
|
|||
.setTitle(R.string.keyboard_layout_set)
|
||||
.setItems(displayNames.toTypedArray()) { di, i ->
|
||||
di.dismiss()
|
||||
val newSubtype = AdditionalSubtypeUtils.createAsciiEmojiCapableAdditionalSubtype(mainLocaleString, layouts[i])
|
||||
val newSubtypeInfo = newSubtype.toSubtypeInfo(mainLocale, context, true, infos.first().hasDictionary) // enabled by default, because why else add them
|
||||
addAdditionalSubtype(prefs, context.resources, newSubtype)
|
||||
addEnabledSubtype(prefs, newSubtype)
|
||||
addSubtypeToView(newSubtypeInfo)
|
||||
infos.add(newSubtypeInfo)
|
||||
reloadSetting()
|
||||
addSubtype(layouts[i])
|
||||
}
|
||||
.setNeutralButton(R.string.button_title_add_custom_layout) { _, _ -> onClickAddCustomSubtype() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
} else
|
||||
binding.addSubtype.isGone = true
|
||||
binding.addSubtype.setOnClickListener { onClickAddCustomSubtype() }
|
||||
|
||||
// add subtypes
|
||||
infos.sortedBy { it.displayName }.forEach {
|
||||
|
@ -98,12 +98,80 @@ class LanguageSettingsDialog(
|
|||
}
|
||||
}
|
||||
|
||||
private fun addSubtype(name: String) {
|
||||
val newSubtype = AdditionalSubtypeUtils.createEmojiCapableAdditionalSubtype(mainLocaleString, name, infos.first().subtype.isAsciiCapable)
|
||||
val newSubtypeInfo = newSubtype.toSubtypeInfo(mainLocale, context, true, infos.first().hasDictionary) // enabled by default
|
||||
val displayName = SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(newSubtype)
|
||||
val old = infos.firstOrNull { isAdditionalSubtype(it.subtype) && displayName == SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(it.subtype) }
|
||||
if (old != null) {
|
||||
KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(context)
|
||||
reloadSetting()
|
||||
return
|
||||
}
|
||||
|
||||
addAdditionalSubtype(prefs, context.resources, newSubtype)
|
||||
addEnabledSubtype(prefs, newSubtype)
|
||||
addSubtypeToView(newSubtypeInfo)
|
||||
KeyboardLayoutSet.onKeyboardThemeChanged()
|
||||
infos.add(newSubtypeInfo)
|
||||
reloadSetting()
|
||||
}
|
||||
|
||||
private fun onClickAddCustomSubtype() {
|
||||
val link = "<a href='$LAYOUT_FORMAT_URL'>" + context.getString(R.string.dictionary_link_text) + "</a>"
|
||||
val message = SpannableStringUtils.fromHtml(context.getString(R.string.message_add_custom_layout, link))
|
||||
val dialog = Builder(context)
|
||||
.setTitle(R.string.button_title_add_custom_layout)
|
||||
.setMessage(message)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setNeutralButton(R.string.button_copy_existing_layout) { _, _ -> copyLayout() }
|
||||
.setPositiveButton(R.string.button_load_custom_layout) { _, _ -> fragment?.requestLayoutFile() }
|
||||
.create()
|
||||
dialog.show()
|
||||
(dialog.findViewById<View>(android.R.id.message) as? TextView)?.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
private fun copyLayout() {
|
||||
val layouts = mutableListOf<String>()
|
||||
val displayNames = mutableListOf<String>()
|
||||
if (infos.first().subtype.isAsciiCapable) {
|
||||
layouts.addAll(context.resources.getStringArray(R.array.predefined_layouts))
|
||||
layouts.forEach { displayNames.add(SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(it) ?: it) }
|
||||
}
|
||||
infos.forEach {
|
||||
val layoutSetName = it.subtype.getExtraValueOf(KEYBOARD_LAYOUT_SET)
|
||||
if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == false) { // don't allow copying custom layout (at least for now)
|
||||
layouts.add(layoutSetName)
|
||||
displayNames.add(SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(it.subtype))
|
||||
}
|
||||
}
|
||||
Builder(context)
|
||||
.setTitle(R.string.keyboard_layout_set)
|
||||
.setItems(displayNames.toTypedArray()) { di, i ->
|
||||
di.dismiss()
|
||||
val fileName = context.assets.list("layouts")!!.firstOrNull { it.startsWith(layouts[i]) } ?: return@setItems
|
||||
loadCustomLayout(context.assets.open("layouts${File.separator}$fileName").reader().readText(), layouts[i], mainLocaleString, context) { addSubtype(it) }
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onNewLayoutFile(uri: Uri?) {
|
||||
loadCustomLayout(uri, mainLocaleString, context) { addSubtype(it) }
|
||||
}
|
||||
|
||||
private fun addSubtypeToView(subtype: SubtypeInfo) {
|
||||
val row = LayoutInflater.from(context).inflate(R.layout.language_list_item, listView)
|
||||
val layoutSetName: String? = subtype.subtype.getExtraValueOf(KEYBOARD_LAYOUT_SET)
|
||||
row.findViewById<TextView>(R.id.language_name).text =
|
||||
SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(subtype.subtype)
|
||||
?: SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(subtype.subtype)
|
||||
row.findViewById<View>(R.id.language_details).isGone = true
|
||||
if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == true) {
|
||||
row.findViewById<TextView>(R.id.language_details).setText(R.string.edit_layout)
|
||||
row.findViewById<View>(R.id.language_text).setOnClickListener { editCustomLayout(layoutSetName, context) }
|
||||
} else {
|
||||
row.findViewById<View>(R.id.language_details).isGone = true
|
||||
}
|
||||
row.findViewById<SwitchCompat>(R.id.language_switch).apply {
|
||||
isChecked = subtype.isEnabled
|
||||
isEnabled = !onlySystemLocales
|
||||
|
@ -127,6 +195,8 @@ class LanguageSettingsDialog(
|
|||
// can be re-added easily, no need for confirmation dialog
|
||||
binding.subtypes.removeView(row)
|
||||
infos.remove(subtype)
|
||||
if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == true)
|
||||
removeCustomLayoutFile(layoutSetName, context)
|
||||
|
||||
removeAdditionalSubtype(prefs, context.resources, subtype.subtype)
|
||||
removeEnabledSubtype(prefs, subtype.subtype)
|
||||
|
@ -353,3 +423,5 @@ private fun getAvailableSecondaryLocales(context: Context, mainLocaleString: Str
|
|||
}
|
||||
return locales
|
||||
}
|
||||
|
||||
private const val LAYOUT_FORMAT_URL = "https://github.com/Helium314/openboard/blob/new/layouts.md"
|
||||
|
|
|
@ -42,6 +42,12 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) {
|
|||
listener?.onNewDictionary(uri)
|
||||
}
|
||||
|
||||
private val layoutFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val uri = it.data?.data ?: return@registerForActivityResult
|
||||
listener?.onNewLayoutFile(uri)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedPreferences = DeviceProtectedUtils.getSharedPreferences(requireContext())
|
||||
|
@ -180,6 +186,7 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) {
|
|||
|
||||
interface Listener {
|
||||
fun onNewDictionary(uri: Uri?)
|
||||
fun onNewLayoutFile(uri: Uri?)
|
||||
}
|
||||
|
||||
private var listener: Listener? = null
|
||||
|
@ -195,6 +202,14 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) {
|
|||
dictionaryFilePicker.launch(intent)
|
||||
}
|
||||
|
||||
fun requestLayoutFile() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
// todo: any working way to allow only json and text files?
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/*", "application/octet-stream")) // doesn't allow opening json files with "application/json"
|
||||
.setType("*/*")
|
||||
layoutFilePicker.launch(intent)
|
||||
}
|
||||
}
|
||||
|
||||
class SubtypeInfo(val displayName: String, val subtype: InputMethodSubtype, var isEnabled: Boolean, var hasDictionary: Boolean) {
|
||||
|
|
|
@ -16,9 +16,12 @@ import org.dslul.openboard.inputmethod.latin.R
|
|||
import org.dslul.openboard.inputmethod.latin.RichInputMethodManager
|
||||
import org.dslul.openboard.inputmethod.latin.define.DebugFlags
|
||||
import org.dslul.openboard.inputmethod.latin.utils.AdditionalSubtypeUtils
|
||||
import org.dslul.openboard.inputmethod.latin.utils.CUSTOM_LAYOUT_PREFIX
|
||||
import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils
|
||||
import org.dslul.openboard.inputmethod.latin.utils.Log
|
||||
import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.collections.LinkedHashMap
|
||||
|
@ -39,15 +42,15 @@ fun getAllAvailableSubtypes(): List<InputMethodSubtype> {
|
|||
return resourceSubtypesByLocale.values.flatten() + additionalSubtypes
|
||||
}
|
||||
|
||||
fun addEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype) {
|
||||
fun addEnabledSubtype(prefs: SharedPreferences, newSubtype: InputMethodSubtype) {
|
||||
require(initialized)
|
||||
val subtypeString = subtype.prefString()
|
||||
val subtypeString = newSubtype.prefString()
|
||||
val oldSubtypeStrings = prefs.getString(Settings.PREF_ENABLED_INPUT_STYLES, "")!!.split(SUBTYPE_SEPARATOR)
|
||||
val newString = (oldSubtypeStrings + subtypeString).filter { it.isNotBlank() }.toSortedSet().joinToString(SUBTYPE_SEPARATOR)
|
||||
prefs.edit { putString(Settings.PREF_ENABLED_INPUT_STYLES, newString) }
|
||||
|
||||
if (subtype !in enabledSubtypes) {
|
||||
enabledSubtypes.add(subtype)
|
||||
if (newSubtype !in enabledSubtypes) {
|
||||
enabledSubtypes.add(newSubtype)
|
||||
enabledSubtypes.sortBy { it.locale() } // for consistent order
|
||||
RichInputMethodManager.getInstance().refreshSubtypeCaches()
|
||||
}
|
||||
|
@ -63,8 +66,9 @@ fun removeEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype)
|
|||
|
||||
fun addAdditionalSubtype(prefs: SharedPreferences, resources: Resources, subtype: InputMethodSubtype) {
|
||||
val oldAdditionalSubtypesString = Settings.readPrefAdditionalSubtypes(prefs, resources)
|
||||
val oldAdditionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toSet()
|
||||
val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes((oldAdditionalSubtypes + subtype).toTypedArray())
|
||||
val additionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toMutableSet()
|
||||
additionalSubtypes.add(subtype)
|
||||
val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes(additionalSubtypes.toTypedArray())
|
||||
Settings.writePrefAdditionalSubtypes(prefs, newAdditionalSubtypesString)
|
||||
}
|
||||
|
||||
|
@ -143,6 +147,7 @@ fun init(context: Context) {
|
|||
reloadSystemLocales(context)
|
||||
|
||||
loadResourceSubtypes(context.resources)
|
||||
removeInvalidCustomSubtypes(context)
|
||||
loadAdditionalSubtypes(context)
|
||||
loadEnabledSubtypes(context)
|
||||
initialized = true
|
||||
|
@ -214,6 +219,23 @@ private fun loadAdditionalSubtypes(context: Context) {
|
|||
additionalSubtypes.addAll(subtypes)
|
||||
}
|
||||
|
||||
// remove custom subtypes without a layout file
|
||||
private fun removeInvalidCustomSubtypes(context: Context) {
|
||||
val prefs = DeviceProtectedUtils.getSharedPreferences(context)
|
||||
val additionalSubtypes = Settings.readPrefAdditionalSubtypes(prefs, context.resources).split(";")
|
||||
val customSubtypeFiles by lazy { File(context.filesDir, "layouts").list() }
|
||||
val subtypesToRemove = mutableListOf<String>()
|
||||
additionalSubtypes.forEach {
|
||||
val name = it.substringAfter(":").substringBefore(":")
|
||||
if (!name.startsWith(CUSTOM_LAYOUT_PREFIX)) return@forEach
|
||||
if (name !in customSubtypeFiles)
|
||||
subtypesToRemove.add(it)
|
||||
}
|
||||
if (subtypesToRemove.isEmpty()) return
|
||||
Log.w(TAG, "removing custom subtypes without files: $subtypesToRemove")
|
||||
Settings.writePrefAdditionalSubtypes(prefs, additionalSubtypes.filterNot { it in subtypesToRemove }.joinToString(";"))
|
||||
}
|
||||
|
||||
// requires loadResourceSubtypes to be called before
|
||||
private fun loadEnabledSubtypes(context: Context) {
|
||||
val prefs = DeviceProtectedUtils.getSharedPreferences(context)
|
||||
|
@ -231,8 +253,10 @@ private fun loadEnabledSubtypes(context: Context) {
|
|||
val subtype = subtypesForLocale.firstOrNull { SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() }
|
||||
?: additionalSubtypes.firstOrNull { it.locale() == localeAndLayout.first() && SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() }
|
||||
if (subtype == null) {
|
||||
val message = "subtype $localeAndLayout could not be loaded"
|
||||
Log.w(TAG, message)
|
||||
if (DebugFlags.DEBUG_ENABLED)
|
||||
Toast.makeText(context, "subtype $localeAndLayout could not be loaded", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
else // don't remove in debug mode
|
||||
removeEnabledSubtype(prefs, localeAndLayout.joinToString(LOCALE_LAYOUT_SEPARATOR))
|
||||
continue
|
||||
|
@ -268,6 +292,7 @@ private val systemSubtypes = mutableListOf<InputMethodSubtype>()
|
|||
|
||||
private const val SUBTYPE_SEPARATOR = ";"
|
||||
private const val LOCALE_LAYOUT_SEPARATOR = ":"
|
||||
private const val TAG = "SubtypeSettings"
|
||||
|
||||
@Suppress("deprecation") // it's deprecated, but no replacement for API < 24
|
||||
// todo: subtypes should now have language tags -> use them for api >= 24
|
||||
|
|
|
@ -8,7 +8,6 @@ package org.dslul.openboard.inputmethod.latin.utils;
|
|||
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
import org.dslul.openboard.inputmethod.latin.utils.Log;
|
||||
import android.view.inputmethod.InputMethodSubtype;
|
||||
|
||||
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
|
||||
|
@ -73,14 +72,12 @@ public final class AdditionalSubtypeUtils {
|
|||
|
||||
public static InputMethodSubtype createDummyAdditionalSubtype(
|
||||
final String localeString, final String keyboardLayoutSetName) {
|
||||
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName,
|
||||
false /* isAsciiCapable */, false /* isEmojiCapable */);
|
||||
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, false, false);
|
||||
}
|
||||
|
||||
public static InputMethodSubtype createAsciiEmojiCapableAdditionalSubtype(
|
||||
final String localeString, final String keyboardLayoutSetName) {
|
||||
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName,
|
||||
true /* isAsciiCapable */, true /* isEmojiCapable */);
|
||||
public static InputMethodSubtype createEmojiCapableAdditionalSubtype(
|
||||
final String localeString, final String keyboardLayoutSetName, final boolean asciiCapable) {
|
||||
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, asciiCapable, true);
|
||||
}
|
||||
|
||||
public static String getPrefSubtype(final InputMethodSubtype subtype) {
|
||||
|
@ -120,11 +117,10 @@ public final class AdditionalSubtypeUtils {
|
|||
}
|
||||
final String localeString = elems[INDEX_OF_LOCALE];
|
||||
final String keyboardLayoutSetName = elems[INDEX_OF_KEYBOARD_LAYOUT];
|
||||
// Here we assume that all the additional subtypes have AsciiCapable and EmojiCapable.
|
||||
// This is actually what the setting dialog for additional subtype is doing.
|
||||
final InputMethodSubtype subtype = createAsciiEmojiCapableAdditionalSubtype(
|
||||
localeString, keyboardLayoutSetName);
|
||||
if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT) {
|
||||
final boolean asciiCapable = ScriptUtils.getScriptFromSpellCheckerLocale(LocaleUtils.constructLocaleFromString(localeString)) == ScriptUtils.SCRIPT_LATIN;
|
||||
// Here we assume that all the additional subtypes are EmojiCapable
|
||||
final InputMethodSubtype subtype = createEmojiCapableAdditionalSubtype(localeString, keyboardLayoutSetName, asciiCapable);
|
||||
if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT && !keyboardLayoutSetName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX)) {
|
||||
// Skip unknown keyboard layout subtype. This may happen when predefined keyboard
|
||||
// layout has been removed.
|
||||
return null;
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
package org.dslul.openboard.inputmethod.latin.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.InputType
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import org.dslul.openboard.inputmethod.keyboard.Key
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardId
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.KeyboardParams
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.JsonKeyboardParser
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.MORE_KEYS_NORMAL
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.SimpleKeyboardParser
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams
|
||||
import org.dslul.openboard.inputmethod.latin.R
|
||||
import org.dslul.openboard.inputmethod.latin.settings.infoDialog
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.math.BigInteger
|
||||
|
||||
fun loadCustomLayout(uri: Uri?, localeString: String, context: Context, onAdded: (String) -> Unit) {
|
||||
if (uri == null)
|
||||
return infoDialog(context, context.getString(R.string.layout_error, "layout file not found"))
|
||||
val layoutContent: String
|
||||
try {
|
||||
val i = context.contentResolver.openInputStream(uri)
|
||||
layoutContent = i?.use { it.reader().readText() } ?: throw IOException()
|
||||
} catch (e: IOException) {
|
||||
return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file"))
|
||||
}
|
||||
|
||||
var name = ""
|
||||
context.contentResolver.query(uri, null, null, null, null).use {
|
||||
if (it != null && it.moveToFirst()) {
|
||||
val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0)
|
||||
name = it.getString(idx).substringBeforeLast(".")
|
||||
}
|
||||
}
|
||||
loadCustomLayout(layoutContent, name, localeString, context, onAdded)
|
||||
}
|
||||
|
||||
fun loadCustomLayout(layoutContent: String, layoutName: String, localeString: String, context: Context, onAdded: (String) -> Unit) {
|
||||
var name = layoutName
|
||||
val isJson = checkLayout(layoutContent, context)
|
||||
?: return infoDialog(context, context.getString(R.string.layout_error, "invalid layout file, ${Log.getLog().lastOrNull { it.tag == TAG }?.message}"))
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(R.string.title_layout_name_select)
|
||||
.setView(EditText(context).apply {
|
||||
setText(name)
|
||||
doAfterTextChanged { name = it.toString() }
|
||||
setPadding(30, 10, 30, 10)
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
// name must be encoded to avoid issues with validity of subtype extra string or file name
|
||||
name = "$CUSTOM_LAYOUT_PREFIX${localeString}.${encodeBase36(name)}.${if (isJson) "json" else "txt"}"
|
||||
val file = getFile(name, context)
|
||||
if (file.exists())
|
||||
file.delete()
|
||||
file.parentFile?.mkdir()
|
||||
file.writeText(layoutContent)
|
||||
onAdded(name)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun checkLayout(layoutContent: String, context: Context): Boolean? {
|
||||
val params = KeyboardParams()
|
||||
params.mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET)
|
||||
params.mMoreKeyTypes.add(MORE_KEYS_LAYOUT)
|
||||
addLocaleKeyTextsToParams(context, params, MORE_KEYS_NORMAL)
|
||||
try {
|
||||
val keys = JsonKeyboardParser(params, context).parseLayoutString(layoutContent)
|
||||
if (!checkKeys(keys))
|
||||
return null
|
||||
return true
|
||||
} catch (e: Exception) { Log.w(TAG, "error parsing custom json layout", e) }
|
||||
try {
|
||||
val keys = SimpleKeyboardParser(params, context).parseLayoutString(layoutContent)
|
||||
if (!checkKeys(keys))
|
||||
return null
|
||||
return false
|
||||
} catch (e: Exception) { Log.w(TAG, "error parsing custom simple layout", e) }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun checkKeys(keys: List<List<Key.KeyParams>>): Boolean {
|
||||
if (keys.isEmpty() || keys.any { it.isEmpty() }) {
|
||||
Log.w(TAG, "empty rows")
|
||||
return false
|
||||
}
|
||||
if (keys.size > 8) {
|
||||
Log.w(TAG, "too many rows")
|
||||
return false
|
||||
}
|
||||
if (keys.any { it.size > 20 }) {
|
||||
Log.w(TAG, "too many keys in one row")
|
||||
return false
|
||||
}
|
||||
if (keys.any { it.any { ((it.mLabel?.length ?: 0) > 6) } }) {
|
||||
Log.w(TAG, "too long text on key")
|
||||
return false
|
||||
}
|
||||
if (keys.any { it.any { (it.mMoreKeys?.size ?: 0) > 20 } }) {
|
||||
Log.w(TAG, "too many popup keys on a key")
|
||||
return false
|
||||
}
|
||||
if (keys.any { it.any { it.mMoreKeys?.any { (it.mLabel?.length ?: 0) > 10 } == true } }) {
|
||||
Log.w(TAG, "too long text on popup key")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getFile(layoutName: String, context: Context) =
|
||||
File(context.filesDir, "layouts${File.separator}$layoutName")
|
||||
|
||||
// undo the name changes in loadCustomLayout when clicking ok
|
||||
fun getLayoutDisplayName(layoutName: String) =
|
||||
try {
|
||||
decodeBase36(layoutName.substringAfter(CUSTOM_LAYOUT_PREFIX).substringAfter(".").substringBeforeLast("."))
|
||||
} catch (_: NumberFormatException) {
|
||||
layoutName
|
||||
}
|
||||
|
||||
fun removeCustomLayoutFile(layoutName: String, context: Context) {
|
||||
getFile(layoutName, context).delete()
|
||||
}
|
||||
|
||||
fun editCustomLayout(layoutName: String, context: Context, startContent: String? = null) {
|
||||
val file = getFile(layoutName, context)
|
||||
val editText = EditText(context).apply {
|
||||
setText(startContent ?: file.readText())
|
||||
}
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(getLayoutDisplayName(layoutName))
|
||||
.setView(editText)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val content = editText.text.toString()
|
||||
val isJson = checkLayout(content, context)
|
||||
if (isJson == null) {
|
||||
editCustomLayout(layoutName, context, content)
|
||||
infoDialog(context, context.getString(R.string.layout_error, Log.getLog().lastOrNull { it.tag == TAG }?.message))
|
||||
} else {
|
||||
val wasJson = file.name.substringAfterLast(".") == "json"
|
||||
file.writeText(content)
|
||||
if (isJson != wasJson) // unlikely to be needed, but better be safe
|
||||
file.renameTo(File(file.absolutePath.substringBeforeLast(".") + if (isJson) "json" else "txt"))
|
||||
KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(context)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun encodeBase36(string: String): String = BigInteger(string.toByteArray()).toString(36)
|
||||
|
||||
private fun decodeBase36(string: String) = BigInteger(string, 36).toByteArray().decodeToString()
|
||||
|
||||
// this goes into prefs and file names, so do not change!
|
||||
const val CUSTOM_LAYOUT_PREFIX = "custom."
|
||||
private const val TAG = "CustomLayoutUtils"
|
|
@ -73,7 +73,6 @@ public class ScriptUtils {
|
|||
mLanguageCodeToScriptCode.put("ko", SCRIPT_HANGUL);
|
||||
mLanguageCodeToScriptCode.put("hi", SCRIPT_DEVANAGARI);
|
||||
mLanguageCodeToScriptCode.put("kn", SCRIPT_KANNADA);
|
||||
mLanguageCodeToScriptCode.put("kh", SCRIPT_KHMER);
|
||||
mLanguageCodeToScriptCode.put("mr", SCRIPT_DEVANAGARI);
|
||||
mLanguageCodeToScriptCode.put("mn", SCRIPT_CYRILLIC);
|
||||
mLanguageCodeToScriptCode.put("be", SCRIPT_CYRILLIC);
|
||||
|
|
|
@ -283,6 +283,8 @@ public final class SubtypeLocaleUtils {
|
|||
|
||||
@Nullable
|
||||
public static String getKeyboardLayoutSetDisplayName(@NonNull final String layoutName) {
|
||||
if (layoutName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX))
|
||||
return CustomLayoutUtilsKt.getLayoutDisplayName(layoutName);
|
||||
return sKeyboardLayoutToDisplayNameMap.get(layoutName);
|
||||
}
|
||||
|
||||
|
|
|
@ -421,8 +421,22 @@ disposition rather than other common dispositions for Latin languages. [CHAR LIM
|
|||
<string name="save">Save</string>
|
||||
<!-- Title of the spinner for choosing a language of custom style in the settings dialog [CHAR LIMIT=15] -->
|
||||
<string name="subtype_locale">Language</string>
|
||||
<!-- Title of the spinner for choosing a keyboard layout of custom style in the settings dialog [CHAR LIMIT=15] -->
|
||||
<!-- Title of the section for choosing a keyboard layout language settings dialog -->
|
||||
<string name="keyboard_layout_set">Layout</string>
|
||||
<!-- Button text and title for adding a custom layout -->
|
||||
<string name="button_title_add_custom_layout">Add custom layout</string>
|
||||
<!-- Message text for adding a custom layout, interpreted as HTML -->
|
||||
<string name="message_add_custom_layout">Select a file in a compatible format. Information about the formats is available %s.</string>
|
||||
<!-- Button text for loading a custom layout file -->
|
||||
<string name="button_load_custom_layout">Load file</string>
|
||||
<!-- Button text for copying an existing layout -->
|
||||
<string name="button_copy_existing_layout">Copy existing layout</string>
|
||||
<!-- Title text for choosing a layout name -->
|
||||
<string name="title_layout_name_select">Set layout name</string>
|
||||
<!-- Message on layout error -->
|
||||
<string name="layout_error">Layout error: %s</string>
|
||||
<!-- Text hint for editing layout -->
|
||||
<string name="edit_layout">Tap to edit raw layout</string>
|
||||
<!-- Title of the settings for keypress vibration duration [CHAR LIMIT=35] -->
|
||||
<string name="prefs_keypress_vibration_duration_settings">Keypress vibration duration</string>
|
||||
<!-- Title of the settings for keypress sound volume [CHAR LIMIT=35] -->
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
package org.dslul.openboard.inputmethod.latin
|
||||
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardId
|
||||
import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.KeyboardParams
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.MORE_KEYS_NORMAL
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.SimpleKeyboardParser
|
||||
import org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowLog
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(shadows = [
|
||||
ShadowInputMethodManager2::class,
|
||||
])
|
||||
class ParserTest {
|
||||
private lateinit var latinIME: LatinIME
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
latinIME = Robolectric.setupService(LatinIME::class.java)
|
||||
ShadowLog.setupLogging()
|
||||
ShadowLog.stream = System.out
|
||||
}
|
||||
|
||||
@Test fun simpleParser() {
|
||||
val params = KeyboardParams()
|
||||
params.mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET)
|
||||
addLocaleKeyTextsToParams(latinIME, params, MORE_KEYS_NORMAL)
|
||||
val layoutStrings = listOf(
|
||||
"""
|
||||
a
|
||||
b
|
||||
c
|
||||
|
||||
d
|
||||
e
|
||||
f
|
||||
""", // normal
|
||||
"""
|
||||
a
|
||||
b
|
||||
c
|
||||
|
||||
d
|
||||
e
|
||||
f
|
||||
""", // spaces in the empty line
|
||||
"""
|
||||
a
|
||||
b
|
||||
c
|
||||
|
||||
d
|
||||
e
|
||||
f
|
||||
""".replace("\n", "\r\n"), // windows file endings
|
||||
"""
|
||||
a
|
||||
b
|
||||
c
|
||||
|
||||
|
||||
d
|
||||
e
|
||||
f
|
||||
|
||||
""", // too many newlines
|
||||
"""
|
||||
a
|
||||
b x
|
||||
c v
|
||||
|
||||
d
|
||||
e
|
||||
f
|
||||
""", // spaces in the end
|
||||
"""
|
||||
a
|
||||
b
|
||||
c
|
||||
|
||||
d
|
||||
e
|
||||
f""", // no newline at the end
|
||||
)
|
||||
val wantedKeyLabels = listOf(listOf("a", "b", "c"), listOf("d", "e", "f"))
|
||||
layoutStrings.forEachIndexed { i, layout ->
|
||||
println(i)
|
||||
val keyLabels = SimpleKeyboardParser(params, latinIME).parseCoreLayout(layout).map { it.map { it.label } }
|
||||
assertEquals(wantedKeyLabels, keyLabels)
|
||||
}
|
||||
}
|
||||
}
|
32
layouts.md
32
layouts.md
|
@ -1,12 +1,26 @@
|
|||
# Layouts
|
||||
A compilation of information about the layout formats usable in this app.
|
||||
|
||||
(WIP) information about the layout format
|
||||
There are two distinct formats:
|
||||
* the _simple_ format is a text file with one key per line, and two consecutive line breaks indicating a switch to the next row, [example](app/src/main/assets/layouts/qwerty.txt)
|
||||
* the _json_ format taken from [FlorisBoard](https://github.com/florisboard/florisboard/blob/master/CONTRIBUTING.md#adding-the-layout), but only "normal" keys are supported (i.e. no action keys and similar), [example](app/src/main/assets/layouts/azerty.json)
|
||||
|
||||
## simple
|
||||
One key per line, two consecutive newlines mark a row end.
|
||||
Key format: [label] [moreKeys], all separated by space, e.g. `a 0 + *` will create a key with text a, and the keys `0`, `+`, and `*` on long press. Some characters currently require escape using `\` (todo: add the list, or better add them in code instead of requiring it in the layouts).
|
||||
Special symbols: `%` (only for language-dependent moreKeys, not user defined, also better use sth like `%%%`) acts as placeholder for normal moreKeys. `$$$` will be replaced by currency (or default to `$`).
|
||||
Language-dependent moreKeys should never contain "special" moreKeys, i.e. those starting with `!` (exception for `punctuation`)
|
||||
## General notes
|
||||
Adding too many keys or too long texts will make the keyboard look awkward or broken, and even crash the app under some specific conditions.
|
||||
There are some sanity checks when adding a layout to avoid such issues, but they do not cover all possible cases.
|
||||
Further there is no check whether the layout actually contains characters of the selected language.
|
||||
|
||||
## json
|
||||
Character layouts from FlorisBoard, but missing code or label will be determined automatically. And not everything supported...
|
||||
If you use an external glide typing library, you likely will have issues if your layout contains duplicate keys, or keys with text longer than a single letter.
|
||||
|
||||
There are special key labels that are intended for internal use only, but can (currently) be set on custom layouts too. An example is `!icon/previous_key|!code/key_action_previous`, so it's unlikely you will stumble upon issues here when not intentionally provoking it.
|
||||
One special label that might be wanted though is `$$$`, which will be replaced by the local currency.
|
||||
|
||||
## Simple format
|
||||
* One key per line
|
||||
* Key format: [label] [popup keys], all separated by space, e.g. `a 0 + *` will create a key with text `a`, and the keys `0`, `+`, and `*` on long press
|
||||
* Two consecutive newlines mark beginning of a new row
|
||||
|
||||
## Json format
|
||||
* You can use character layouts from FlorisBoard
|
||||
* There is no need for specifying a code, it will be determined from the label automatically
|
||||
* Specify it if you want key label and code to be different
|
||||
* You can add a _labelFlag_ to a key for some specific effects, see [here](app/src/main/res/values/attrs.xml) in the section _keyLabelFlags_ for names and numeric values
|
||||
|
|
Loading…
Add table
Reference in a new issue