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

@ -22,6 +22,7 @@ Might end up on F-Droid...
* Number pad * Number pad
* Show all available extra characters on long pressing a key * Show all available extra characters on long pressing a key
* Backup your learned word / history data * Backup your learned word / history data
* Add custom keyboard layouts
* Adjustable bottom padding * Adjustable bottom padding
## Hidden functionality ## Hidden functionality

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; dividerWidth = 0;
} }
final MoreKeySpec[] moreKeys = key.getMoreKeys(); 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, rowHeight, key.getX() + key.getWidth() / 2, keyboard.mId.mWidth,
key.isMoreKeysFixedColumn(), key.isMoreKeysFixedOrder(), dividerWidth); 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.keyboard.Key;
import org.dslul.openboard.inputmethod.latin.R; import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.StringUtilsKt;
import java.util.HashSet; import java.util.HashSet;
@ -66,6 +67,10 @@ public class KeyPreviewView extends AppCompatTextView {
if (sNoScaleXTextSet.contains(text)) { if (sNoScaleXTextSet.contains(text)) {
return; return;
} }
if (StringUtilsKt.isEmoji(text)) {
sNoScaleXTextSet.add(text);
return;
}
// TODO: Override {@link #setBackground(Drawable)} that is supported from API 16 and // TODO: Override {@link #setBackground(Drawable)} that is supported from API 16 and
// calculate maximum text width. // calculate maximum text width.
final Drawable background = getBackground(); 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.Constants;
import org.dslul.openboard.inputmethod.latin.common.StringUtils; 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_OUTPUT_TEXT;
import static org.dslul.openboard.inputmethod.latin.common.Constants.CODE_UNSPECIFIED; 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. // Treat a sole vertical bar as a special case of key label.
return -1; return -1;
} }
throw new KeySpecParserError("Empty label"); if (DebugFlags.DEBUG_ENABLED)
throw new KeySpecParserError("Empty label");
else return -1;
} }
return labelEnd; return labelEnd;
} }
@ -118,7 +121,8 @@ public final class KeySpecParser {
if (indexOfLabelEnd(getAfterLabelEnd(keySpec, labelEnd)) < 0) { if (indexOfLabelEnd(getAfterLabelEnd(keySpec, labelEnd)) < 0) {
return; return;
} }
throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec); if (DebugFlags.DEBUG_ENABLED)
throw new KeySpecParserError("Multiple " + VERTICAL_BAR + ": " + keySpec);
} }
@Nullable @Nullable
@ -132,7 +136,7 @@ public final class KeySpecParser {
} }
final int labelEnd = indexOfLabelEnd(keySpec); final int labelEnd = indexOfLabelEnd(keySpec);
final String label = parseEscape(getBeforeLabelEnd(keySpec, labelEnd)); final String label = parseEscape(getBeforeLabelEnd(keySpec, labelEnd));
if (label.isEmpty()) { if (label.isEmpty() && DebugFlags.DEBUG_ENABLED) {
throw new KeySpecParserError("Empty label: " + keySpec); throw new KeySpecParserError("Empty label: " + keySpec);
} }
return label; return label;
@ -164,13 +168,13 @@ public final class KeySpecParser {
// See {@link #getCode(Resources, String)}. // See {@link #getCode(Resources, String)}.
return null; return null;
} }
if (outputText.isEmpty()) { if (outputText.isEmpty() && DebugFlags.DEBUG_ENABLED) {
throw new KeySpecParserError("Empty outputText: " + keySpec); throw new KeySpecParserError("Empty outputText: " + keySpec);
} }
return outputText; return outputText;
} }
final String label = getLabel(keySpec); final String label = getLabel(keySpec);
if (label == null) { if (label == null && DebugFlags.DEBUG_ENABLED) {
throw new KeySpecParserError("Empty label: " + keySpec); throw new KeySpecParserError("Empty label: " + keySpec);
} }
// Code is automatically generated for one letter label. See {@link getCode()}. // Code is automatically generated for one letter label. See {@link getCode()}.
@ -198,7 +202,9 @@ public final class KeySpecParser {
} }
final String label = getLabel(keySpec); final String label = getLabel(keySpec);
if (label == null) { 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. // Code is automatically generated for one letter label.
return (StringUtils.codePointCount(label) == 1) ? label.codePointAt(0) : CODE_OUTPUT_TEXT; 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 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> { fun load(xmlId: Int, id: KeyboardId): KeyboardBuilder<KP> {
mParams.mId = id mParams.mId = id
if (id.isEmojiKeyboard) { if (id.isEmojiKeyboard) {
@ -120,7 +60,10 @@ open class KeyboardBuilder<KP : KeyboardParams>(protected val mContext: Context,
mParams.mMoreKeyTypes.addAll(sv.mMoreKeyTypes) mParams.mMoreKeyTypes.addAll(sv.mMoreKeyTypes)
// add label source only if moreKey type enabled // add label source only if moreKey type enabled
sv.mMoreKeyLabelSources.forEach { if (it in sv.mMoreKeyTypes) mParams.mMoreKeyLabelSources.add(it) } 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() determineAbsoluteValues()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "error parsing layout $id ${id.mElementId}", e) 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 package org.dslul.openboard.inputmethod.keyboard.internal.keyboard_parser
import android.content.Context import android.content.Context
@ -6,7 +7,6 @@ import android.content.res.Resources
import android.os.Build import android.os.Build
import org.dslul.openboard.inputmethod.latin.utils.Log import org.dslul.openboard.inputmethod.latin.utils.Log
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.dslul.openboard.inputmethod.keyboard.Key import org.dslul.openboard.inputmethod.keyboard.Key
import org.dslul.openboard.inputmethod.keyboard.Key.KeyParams 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.keyboard.internal.keyboard_parser.floris.SimplePopups
import org.dslul.openboard.inputmethod.latin.R import org.dslul.openboard.inputmethod.latin.R
import org.dslul.openboard.inputmethod.latin.common.Constants 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.common.splitOnWhitespace
import org.dslul.openboard.inputmethod.latin.define.DebugFlags import org.dslul.openboard.inputmethod.latin.define.DebugFlags
import org.dslul.openboard.inputmethod.latin.settings.Settings import org.dslul.openboard.inputmethod.latin.settings.Settings
import org.dslul.openboard.inputmethod.latin.spellcheck.AndroidSpellCheckerService 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.InputTypeUtils
import org.dslul.openboard.inputmethod.latin.utils.MORE_KEYS_LAYOUT 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.MORE_KEYS_NUMBER
import org.dslul.openboard.inputmethod.latin.utils.RunInLocale import org.dslul.openboard.inputmethod.latin.utils.RunInLocale
import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils
import org.dslul.openboard.inputmethod.latin.utils.sumOf import org.dslul.openboard.inputmethod.latin.utils.sumOf
import java.io.File
import java.util.Locale 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 // 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 // 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 } baseKeys.first().take(10).forEachIndexed { index, keyData -> keyData.popup.numberIndex = index }
if (DebugFlags.DEBUG_ENABLED && baseKeys.first().size < 10) { if (baseKeys.first().size < 10) {
val message = "first row only has ${baseKeys.first().size} keys: ${baseKeys.first().map { it.label }}" Log.w(TAG, "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()
} }
} }
@ -188,9 +189,10 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
} }
for (key in row) { for (key in row) {
// todo: maybe autoScale / autoXScale if label has more than 2 characters (exception for emojis?) val extraFlags = if (key.label.length > 2 && key.label.codePointCount(0, key.label.length) > 2 && !isEmoji(key.label))
// but that could also be determined in toKeyParams Key.LABEL_FLAGS_AUTO_X_SCALE
val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags) else 0
val keyParams = key.compute(params).toKeyParams(params, keyWidth, defaultLabelFlags or extraFlags)
paramsRow.add(keyParams) paramsRow.add(keyParams)
if (DebugFlags.DEBUG_ENABLED) if (DebugFlags.DEBUG_ENABLED)
Log.d(TAG, "adding key ${keyParams.mLabel}, ${keyParams.mCode}") 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 val rowAboveLastNormalRowKeyWidth = rowAboveLastNormalRow.first { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL }.mRelativeWidth
if (lastNormalRowKeyWidth <= rowAboveLastNormalRowKeyWidth + 0.0001f) if (lastNormalRowKeyWidth <= rowAboveLastNormalRowKeyWidth + 0.0001f)
return // no need 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 }) if (lastNormalRow.any { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL && it.mRelativeWidth != lastNormalRowKeyWidth })
return // normal keys have different width, don't deal with this return // normal keys have different width, don't deal with this
val numberOfNormalKeys = lastNormalRow.count { it.mBackgroundType == Key.BACKGROUND_TYPE_NORMAL } 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 return this
val id = context.resources.getIdentifier("label_$this", "string", context.packageName) val id = context.resources.getIdentifier("label_$this", "string", context.packageName)
if (id == 0) { if (id == 0) {
val message = "no resource for label $this in ${params.mId}" Log.w(TAG, "no resource for label $this in ${params.mId}")
Log.w(TAG, message)
if (DebugFlags.DEBUG_ENABLED)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
return this return this
} }
return getInLocale(id) return getInLocale(id)
@ -782,6 +783,15 @@ abstract class KeyboardParser(private val params: KeyboardParams, private val co
companion object { companion object {
private val TAG = KeyboardParser::class.simpleName 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>> { fun parseFromAssets(params: KeyboardParams, context: Context): ArrayList<ArrayList<KeyParams>> {
val id = params.mId val id = params.mId
val layoutName = params.mId.mSubtype.keyboardLayoutSetName.substringBefore("+") 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 // touchPositionCorrectionData needs to be loaded, currently always holo is applied in readAttributes
private fun layoutInfos(params: KeyboardParams): LayoutInfos { private fun layoutInfos(params: KeyboardParams): LayoutInfos {
val name = params.mId.mSubtype.keyboardLayoutSetName val name = params.mId.mSubtype.keyboardLayoutSetName
val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (name) { val language = params.mId.locale.language
"armenian_phonetic", "arabic", "arabic_pc", "bengali", "bengali_akkhor", "bengali_unijoy", val labelFlags = if (!params.mId.isAlphabetKeyboard) 0 else when (language) {
"farsi", "hindi", "hindi_compact", "lao", "marathi", "nepali_romanized", "nepali_traditional", "hy", "ar", "be", "fa", "hi", "lo", "mr", "ne", "th", "ur" -> Key.LABEL_FLAGS_FONT_NORMAL
"thai", "urdu" -> Key.LABEL_FLAGS_FONT_NORMAL "kn", "km", "ml", "si", "ta", "te" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE
"kannada", "khmer", "malayalam", "sinhala", "tamil", "telugu" -> Key.LABEL_FLAGS_FONT_NORMAL or Key.LABEL_FLAGS_AUTO_X_SCALE
else -> 0 else -> 0
} }
// only for alphabet, but some exceptions for shift layouts // only for alphabet, but some exceptions for shift layouts
val enableProximityCharsCorrection = params.mId.isAlphabetKeyboard && when (name) { val enableProximityCharsCorrection = params.mId.isAlphabetKeyboard && when (name) {
// todo: test effect on correction (just add qwerty to the list for testing) // 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 params.mId.mElementId == KeyboardId.ELEMENT_ALPHABET
else -> true 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 // 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: check the difference between default (i.e. none) and holo (test behavior on keyboard)
// todo: null for MoreKeysKeyboard only // 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 R.array.touch_position_correction_data_default
else R.array.touch_position_correction_data_holo 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 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") 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) 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() context.assets.open("layouts/$layoutName.txt").reader().readText()
override fun parseCoreLayout(layoutContent: String): MutableList<List<KeyData>> { 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 -> return rowStrings.mapIndexedNotNullTo(mutableListOf()) { i, row ->
if (row.isBlank()) return@mapIndexedNotNullTo null if (row.isBlank()) return@mapIndexedNotNullTo null
if (addExtraKeys) if (addExtraKeys)

View file

@ -7,12 +7,13 @@
package org.dslul.openboard.inputmethod.latin; package org.dslul.openboard.inputmethod.latin;
import android.os.Build; import android.os.Build;
import org.dslul.openboard.inputmethod.latin.utils.Log;
import android.view.inputmethod.InputMethodSubtype; import android.view.inputmethod.InputMethodSubtype;
import org.dslul.openboard.inputmethod.compat.InputMethodSubtypeCompatUtils; import org.dslul.openboard.inputmethod.compat.InputMethodSubtypeCompatUtils;
import org.dslul.openboard.inputmethod.latin.common.Constants; import org.dslul.openboard.inputmethod.latin.common.Constants;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils; 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 org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils;
import java.util.HashMap; import java.util.HashMap;
@ -72,6 +73,10 @@ public class RichInputMethodSubtype {
return SubtypeLocaleUtils.NO_LANGUAGE.equals(mSubtype.getLocale()); return SubtypeLocaleUtils.NO_LANGUAGE.equals(mSubtype.getLocale());
} }
public boolean isCustom() {
return getKeyboardLayoutSetName().startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX);
}
public String getNameForLogging() { public String getNameForLogging() {
return toString(); 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 var libfile: File? = null
private val backupFilePatterns by lazy { listOf( private val backupFilePatterns by lazy { listOf(
"blacklists/.*\\.txt".toRegex(), "blacklists/.*\\.txt".toRegex(),
"layouts/.*.(txt|json)".toRegex(),
"dicts/.*/.*user\\.dict".toRegex(), "dicts/.*/.*user\\.dict".toRegex(),
"userunigram.*/userunigram.*\\.(body|header)".toRegex(), "userunigram.*/userunigram.*\\.(body|header)".toRegex(),
"UserHistoryDictionary.*/UserHistoryDictionary.*\\.(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.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.text.InputType
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
@ -17,10 +19,13 @@ import androidx.core.view.get
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.size import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged
import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants import org.dslul.openboard.inputmethod.dictionarypack.DictionaryPackConstants
import org.dslul.openboard.inputmethod.keyboard.KeyboardLayoutSet 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.BinaryDictionaryGetter
import org.dslul.openboard.inputmethod.latin.R 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.common.LocaleUtils
import org.dslul.openboard.inputmethod.latin.databinding.LanguageListItemBinding import org.dslul.openboard.inputmethod.latin.databinding.LanguageListItemBinding
import org.dslul.openboard.inputmethod.latin.databinding.LocaleSettingsDialogBinding import org.dslul.openboard.inputmethod.latin.databinding.LocaleSettingsDialogBinding
@ -69,7 +74,7 @@ class LanguageSettingsDialog(
} }
private fun fillSubtypesView() { 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 { binding.addSubtype.setOnClickListener {
val layouts = context.resources.getStringArray(R.array.predefined_layouts) val layouts = context.resources.getStringArray(R.array.predefined_layouts)
.filterNot { layoutName -> infos.any { SubtypeLocaleUtils.getKeyboardLayoutSetName(it.subtype) == layoutName } } .filterNot { layoutName -> infos.any { SubtypeLocaleUtils.getKeyboardLayoutSetName(it.subtype) == layoutName } }
@ -78,19 +83,14 @@ class LanguageSettingsDialog(
.setTitle(R.string.keyboard_layout_set) .setTitle(R.string.keyboard_layout_set)
.setItems(displayNames.toTypedArray()) { di, i -> .setItems(displayNames.toTypedArray()) { di, i ->
di.dismiss() di.dismiss()
val newSubtype = AdditionalSubtypeUtils.createAsciiEmojiCapableAdditionalSubtype(mainLocaleString, layouts[i]) addSubtype(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()
} }
.setNeutralButton(R.string.button_title_add_custom_layout) { _, _ -> onClickAddCustomSubtype() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
} else } else
binding.addSubtype.isGone = true binding.addSubtype.setOnClickListener { onClickAddCustomSubtype() }
// add subtypes // add subtypes
infos.sortedBy { it.displayName }.forEach { 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) { private fun addSubtypeToView(subtype: SubtypeInfo) {
val row = LayoutInflater.from(context).inflate(R.layout.language_list_item, listView) 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 = row.findViewById<TextView>(R.id.language_name).text =
SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(subtype.subtype) SubtypeLocaleUtils.getKeyboardLayoutSetDisplayName(subtype.subtype)
?: SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(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 { row.findViewById<SwitchCompat>(R.id.language_switch).apply {
isChecked = subtype.isEnabled isChecked = subtype.isEnabled
isEnabled = !onlySystemLocales isEnabled = !onlySystemLocales
@ -127,6 +195,8 @@ class LanguageSettingsDialog(
// can be re-added easily, no need for confirmation dialog // can be re-added easily, no need for confirmation dialog
binding.subtypes.removeView(row) binding.subtypes.removeView(row)
infos.remove(subtype) infos.remove(subtype)
if (layoutSetName?.startsWith(CUSTOM_LAYOUT_PREFIX) == true)
removeCustomLayoutFile(layoutSetName, context)
removeAdditionalSubtype(prefs, context.resources, subtype.subtype) removeAdditionalSubtype(prefs, context.resources, subtype.subtype)
removeEnabledSubtype(prefs, subtype.subtype) removeEnabledSubtype(prefs, subtype.subtype)
@ -353,3 +423,5 @@ private fun getAvailableSecondaryLocales(context: Context, mainLocaleString: Str
} }
return locales 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) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
sharedPreferences = DeviceProtectedUtils.getSharedPreferences(requireContext()) sharedPreferences = DeviceProtectedUtils.getSharedPreferences(requireContext())
@ -180,6 +186,7 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) {
interface Listener { interface Listener {
fun onNewDictionary(uri: Uri?) fun onNewDictionary(uri: Uri?)
fun onNewLayoutFile(uri: Uri?)
} }
private var listener: Listener? = null private var listener: Listener? = null
@ -195,6 +202,14 @@ class LanguageSettingsFragment : Fragment(R.layout.language_settings) {
dictionaryFilePicker.launch(intent) 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) { 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.RichInputMethodManager
import org.dslul.openboard.inputmethod.latin.define.DebugFlags import org.dslul.openboard.inputmethod.latin.define.DebugFlags
import org.dslul.openboard.inputmethod.latin.utils.AdditionalSubtypeUtils 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.DeviceProtectedUtils
import org.dslul.openboard.inputmethod.latin.utils.Log
import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.collections.LinkedHashMap import kotlin.collections.LinkedHashMap
@ -39,15 +42,15 @@ fun getAllAvailableSubtypes(): List<InputMethodSubtype> {
return resourceSubtypesByLocale.values.flatten() + additionalSubtypes return resourceSubtypesByLocale.values.flatten() + additionalSubtypes
} }
fun addEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype) { fun addEnabledSubtype(prefs: SharedPreferences, newSubtype: InputMethodSubtype) {
require(initialized) require(initialized)
val subtypeString = subtype.prefString() val subtypeString = newSubtype.prefString()
val oldSubtypeStrings = prefs.getString(Settings.PREF_ENABLED_INPUT_STYLES, "")!!.split(SUBTYPE_SEPARATOR) val oldSubtypeStrings = prefs.getString(Settings.PREF_ENABLED_INPUT_STYLES, "")!!.split(SUBTYPE_SEPARATOR)
val newString = (oldSubtypeStrings + subtypeString).filter { it.isNotBlank() }.toSortedSet().joinToString(SUBTYPE_SEPARATOR) val newString = (oldSubtypeStrings + subtypeString).filter { it.isNotBlank() }.toSortedSet().joinToString(SUBTYPE_SEPARATOR)
prefs.edit { putString(Settings.PREF_ENABLED_INPUT_STYLES, newString) } prefs.edit { putString(Settings.PREF_ENABLED_INPUT_STYLES, newString) }
if (subtype !in enabledSubtypes) { if (newSubtype !in enabledSubtypes) {
enabledSubtypes.add(subtype) enabledSubtypes.add(newSubtype)
enabledSubtypes.sortBy { it.locale() } // for consistent order enabledSubtypes.sortBy { it.locale() } // for consistent order
RichInputMethodManager.getInstance().refreshSubtypeCaches() RichInputMethodManager.getInstance().refreshSubtypeCaches()
} }
@ -63,8 +66,9 @@ fun removeEnabledSubtype(prefs: SharedPreferences, subtype: InputMethodSubtype)
fun addAdditionalSubtype(prefs: SharedPreferences, resources: Resources, subtype: InputMethodSubtype) { fun addAdditionalSubtype(prefs: SharedPreferences, resources: Resources, subtype: InputMethodSubtype) {
val oldAdditionalSubtypesString = Settings.readPrefAdditionalSubtypes(prefs, resources) val oldAdditionalSubtypesString = Settings.readPrefAdditionalSubtypes(prefs, resources)
val oldAdditionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toSet() val additionalSubtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(oldAdditionalSubtypesString).toMutableSet()
val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes((oldAdditionalSubtypes + subtype).toTypedArray()) additionalSubtypes.add(subtype)
val newAdditionalSubtypesString = AdditionalSubtypeUtils.createPrefSubtypes(additionalSubtypes.toTypedArray())
Settings.writePrefAdditionalSubtypes(prefs, newAdditionalSubtypesString) Settings.writePrefAdditionalSubtypes(prefs, newAdditionalSubtypesString)
} }
@ -143,6 +147,7 @@ fun init(context: Context) {
reloadSystemLocales(context) reloadSystemLocales(context)
loadResourceSubtypes(context.resources) loadResourceSubtypes(context.resources)
removeInvalidCustomSubtypes(context)
loadAdditionalSubtypes(context) loadAdditionalSubtypes(context)
loadEnabledSubtypes(context) loadEnabledSubtypes(context)
initialized = true initialized = true
@ -214,6 +219,23 @@ private fun loadAdditionalSubtypes(context: Context) {
additionalSubtypes.addAll(subtypes) 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 // requires loadResourceSubtypes to be called before
private fun loadEnabledSubtypes(context: Context) { private fun loadEnabledSubtypes(context: Context) {
val prefs = DeviceProtectedUtils.getSharedPreferences(context) val prefs = DeviceProtectedUtils.getSharedPreferences(context)
@ -231,8 +253,10 @@ private fun loadEnabledSubtypes(context: Context) {
val subtype = subtypesForLocale.firstOrNull { SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() } val subtype = subtypesForLocale.firstOrNull { SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() }
?: additionalSubtypes.firstOrNull { it.locale() == localeAndLayout.first() && SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() } ?: additionalSubtypes.firstOrNull { it.locale() == localeAndLayout.first() && SubtypeLocaleUtils.getKeyboardLayoutSetName(it) == localeAndLayout.last() }
if (subtype == null) { if (subtype == null) {
val message = "subtype $localeAndLayout could not be loaded"
Log.w(TAG, message)
if (DebugFlags.DEBUG_ENABLED) 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 else // don't remove in debug mode
removeEnabledSubtype(prefs, localeAndLayout.joinToString(LOCALE_LAYOUT_SEPARATOR)) removeEnabledSubtype(prefs, localeAndLayout.joinToString(LOCALE_LAYOUT_SEPARATOR))
continue continue
@ -268,6 +292,7 @@ private val systemSubtypes = mutableListOf<InputMethodSubtype>()
private const val SUBTYPE_SEPARATOR = ";" private const val SUBTYPE_SEPARATOR = ";"
private const val LOCALE_LAYOUT_SEPARATOR = ":" private const val LOCALE_LAYOUT_SEPARATOR = ":"
private const val TAG = "SubtypeSettings"
@Suppress("deprecation") // it's deprecated, but no replacement for API < 24 @Suppress("deprecation") // it's deprecated, but no replacement for API < 24
// todo: subtypes should now have language tags -> use them 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.os.Build;
import android.text.TextUtils; import android.text.TextUtils;
import org.dslul.openboard.inputmethod.latin.utils.Log;
import android.view.inputmethod.InputMethodSubtype; import android.view.inputmethod.InputMethodSubtype;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting; import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
@ -73,14 +72,12 @@ public final class AdditionalSubtypeUtils {
public static InputMethodSubtype createDummyAdditionalSubtype( public static InputMethodSubtype createDummyAdditionalSubtype(
final String localeString, final String keyboardLayoutSetName) { final String localeString, final String keyboardLayoutSetName) {
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, false, false);
false /* isAsciiCapable */, false /* isEmojiCapable */);
} }
public static InputMethodSubtype createAsciiEmojiCapableAdditionalSubtype( public static InputMethodSubtype createEmojiCapableAdditionalSubtype(
final String localeString, final String keyboardLayoutSetName) { final String localeString, final String keyboardLayoutSetName, final boolean asciiCapable) {
return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, return createAdditionalSubtypeInternal(localeString, keyboardLayoutSetName, asciiCapable, true);
true /* isAsciiCapable */, true /* isEmojiCapable */);
} }
public static String getPrefSubtype(final InputMethodSubtype subtype) { public static String getPrefSubtype(final InputMethodSubtype subtype) {
@ -120,11 +117,10 @@ public final class AdditionalSubtypeUtils {
} }
final String localeString = elems[INDEX_OF_LOCALE]; final String localeString = elems[INDEX_OF_LOCALE];
final String keyboardLayoutSetName = elems[INDEX_OF_KEYBOARD_LAYOUT]; final String keyboardLayoutSetName = elems[INDEX_OF_KEYBOARD_LAYOUT];
// Here we assume that all the additional subtypes have AsciiCapable and EmojiCapable. final boolean asciiCapable = ScriptUtils.getScriptFromSpellCheckerLocale(LocaleUtils.constructLocaleFromString(localeString)) == ScriptUtils.SCRIPT_LATIN;
// This is actually what the setting dialog for additional subtype is doing. // Here we assume that all the additional subtypes are EmojiCapable
final InputMethodSubtype subtype = createAsciiEmojiCapableAdditionalSubtype( final InputMethodSubtype subtype = createEmojiCapableAdditionalSubtype(localeString, keyboardLayoutSetName, asciiCapable);
localeString, keyboardLayoutSetName); if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT && !keyboardLayoutSetName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX)) {
if (subtype.getNameResId() == SubtypeLocaleUtils.UNKNOWN_KEYBOARD_LAYOUT) {
// Skip unknown keyboard layout subtype. This may happen when predefined keyboard // Skip unknown keyboard layout subtype. This may happen when predefined keyboard
// layout has been removed. // layout has been removed.
return null; 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("ko", SCRIPT_HANGUL);
mLanguageCodeToScriptCode.put("hi", SCRIPT_DEVANAGARI); mLanguageCodeToScriptCode.put("hi", SCRIPT_DEVANAGARI);
mLanguageCodeToScriptCode.put("kn", SCRIPT_KANNADA); mLanguageCodeToScriptCode.put("kn", SCRIPT_KANNADA);
mLanguageCodeToScriptCode.put("kh", SCRIPT_KHMER);
mLanguageCodeToScriptCode.put("mr", SCRIPT_DEVANAGARI); mLanguageCodeToScriptCode.put("mr", SCRIPT_DEVANAGARI);
mLanguageCodeToScriptCode.put("mn", SCRIPT_CYRILLIC); mLanguageCodeToScriptCode.put("mn", SCRIPT_CYRILLIC);
mLanguageCodeToScriptCode.put("be", SCRIPT_CYRILLIC); mLanguageCodeToScriptCode.put("be", SCRIPT_CYRILLIC);

View file

@ -283,6 +283,8 @@ public final class SubtypeLocaleUtils {
@Nullable @Nullable
public static String getKeyboardLayoutSetDisplayName(@NonNull final String layoutName) { public static String getKeyboardLayoutSetDisplayName(@NonNull final String layoutName) {
if (layoutName.startsWith(CustomLayoutUtilsKt.CUSTOM_LAYOUT_PREFIX))
return CustomLayoutUtilsKt.getLayoutDisplayName(layoutName);
return sKeyboardLayoutToDisplayNameMap.get(layoutName); return sKeyboardLayoutToDisplayNameMap.get(layoutName);
} }

View file

@ -421,8 +421,22 @@ disposition rather than other common dispositions for Latin languages. [CHAR LIM
<string name="save">Save</string> <string name="save">Save</string>
<!-- Title of the spinner for choosing a language of custom style in the settings dialog [CHAR LIMIT=15] --> <!-- Title of the spinner for choosing a language of custom style in the settings dialog [CHAR LIMIT=15] -->
<string name="subtype_locale">Language</string> <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> <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] --> <!-- Title of the settings for keypress vibration duration [CHAR LIMIT=35] -->
<string name="prefs_keypress_vibration_duration_settings">Keypress vibration duration</string> <string name="prefs_keypress_vibration_duration_settings">Keypress vibration duration</string>
<!-- Title of the settings for keypress sound volume [CHAR LIMIT=35] --> <!-- Title of the settings for keypress sound volume [CHAR LIMIT=35] -->

View file

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

View file

@ -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 ## General notes
One key per line, two consecutive newlines mark a row end. 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.
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). There are some sanity checks when adding a layout to avoid such issues, but they do not cover all possible cases.
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 `$`). Further there is no check whether the layout actually contains characters of the selected language.
Language-dependent moreKeys should never contain "special" moreKeys, i.e. those starting with `!` (exception for `punctuation`)
## json 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.
Character layouts from FlorisBoard, but missing code or label will be determined automatically. And not everything supported...
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