Make layouts customizable (#361)

now layouts can be added from files, or default layouts can be copied and edited
This commit is contained in:
Helium314 2023-12-30 23:18:10 +01:00 committed by GitHub
parent 450338c080
commit 54abf44628
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 522 additions and 129 deletions

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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;

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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(),

View file

@ -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"

View file

@ -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) {

View file

@ -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

View file

@ -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;

View file

@ -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"

View file

@ -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);

View file

@ -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);
}