Skip to content

Commit 7a86619

Browse files
committed
feat(study-screen): nosuggest:type field filter
This removes suggestions from the IME, so a language learner can learn an orthography without hints. It also removes the 'swipe' operation on the keyboard without showing the incognito icon. This input mode (TYPE_NULL) will not be for all users: it makes Mandarin Chinese keyboards output in QWERTY only mode, and likely will disable voice input. It is to be treated as an advanced feature, and is currently a hidden feature, planned to be exposed by the FieldFilter class. `nosuggest` needs to come before `type` in the field filter list: `type` is processed by the backend, and AnkiDroid processes the remaining 'custom' filters so: `{{nosuggest:type:Field}}` becomes [[type:nosuggest:Field]] which is exposed by TypeAnswer, and implemented by the study screen ---- The following were considered: TYPE_TEXT_FLAG_AUTO_COMPLETE = false TYPE_TEXT_FLAG_AUTO_CORRECT = false TYPE_TEXT_FLAG_AUTO_CORRECT = false TYPE_TEXT_VARIATION_FILTER = true IME_FLAG_NO_PERSONALIZED_LEARNING = true The above are not sufficient for GBoard - incognito shows, and the strip showing suggested words is still visible `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD` removed suggestions, but with an in-IME hint to open the password manager and a monospaced font on the field Fixes 10352 https://developer.android.com/reference/android/text/InputType#TYPE_NULL Assisted-by: Claude Opus 4.7 - the code and a lot of bad research
1 parent 195046a commit 7a86619

12 files changed

Lines changed: 388 additions & 97 deletions

File tree

.idea/dictionaries/project.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<component name="ProjectDictionaryState">
2+
<dictionary name="project">
3+
<words>
4+
<w>nosuggest</w>
5+
</words>
6+
</dictionary>
7+
</component>

AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import android.os.Bundle
3636
import android.os.Handler
3737
import android.os.Looper
3838
import android.os.SystemClock
39+
import android.text.InputType
3940
import android.view.GestureDetector
4041
import android.view.GestureDetector.SimpleOnGestureListener
4142
import android.view.KeyEvent
@@ -71,6 +72,7 @@ import androidx.annotation.IdRes
7172
import androidx.annotation.RequiresApi
7273
import androidx.annotation.VisibleForTesting
7374
import androidx.appcompat.app.AlertDialog
75+
import androidx.core.content.getSystemService
7476
import androidx.core.net.toFile
7577
import androidx.core.net.toUri
7678
import androidx.core.view.WindowInsetsCompat
@@ -127,6 +129,7 @@ import com.ichi2.anki.libanki.SoundOrVideoTag
127129
import com.ichi2.anki.libanki.TTSTag
128130
import com.ichi2.anki.libanki.TtsPlayer
129131
import com.ichi2.anki.model.CardStateFilter
132+
import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
130133
import com.ichi2.anki.multimedia.getAvTag
131134
import com.ichi2.anki.noteeditor.NoteEditorLauncher
132135
import com.ichi2.anki.observability.ChangeManager
@@ -245,6 +248,10 @@ abstract class AbstractFlashcardViewer :
245248
private var cardFrame: FrameLayout? = null
246249
private var touchLayer: FrameLayout? = null
247250
protected var answerField: FixedEditText? = null
251+
252+
/** Layout-provided default `inputType` for [answerField], captured once and used to restore
253+
* state when moving off a card that used `{{nosuggest:type:}}`. See issue #10352. */
254+
private var defaultAnswerFieldInputType: Int? = null
248255
protected var flipCardLayout: FrameLayout? = null
249256
private var easeButtonsLayout: LinearLayout? = null
250257

@@ -569,6 +576,7 @@ abstract class AbstractFlashcardViewer :
569576
shortAnimDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
570577
gestureDetectorImpl = LinkDetectingGestureDetector()
571578
TtsVoicesFieldFilter.ensureApplied()
579+
NoSuggestFilter.ensureApplied()
572580
}
573581

574582
override fun setupBackPressedCallbacks() {
@@ -737,6 +745,20 @@ abstract class AbstractFlashcardViewer :
737745

738746
protected open fun answerFieldIsFocused(): Boolean = answerField != null && answerField!!.isFocused
739747

748+
/**
749+
* Apply or restore the `{{nosuggest:type:}}` flag set on [answerField].
750+
*/
751+
private fun applyTypeAnswerSuggestionFlags(noSuggest: Boolean) {
752+
val field = answerField ?: return
753+
// see ReviewerFragment for why `TYPE_NULL` was selected
754+
val targetInputType =
755+
if (noSuggest) InputType.TYPE_NULL else (defaultAnswerFieldInputType ?: field.inputType)
756+
if (field.inputType != targetInputType) {
757+
field.inputType = targetInputType
758+
getSystemService<InputMethodManager>()?.restartInput(field)
759+
}
760+
}
761+
740762
val deckOptionsLauncher =
741763
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
742764
Timber.i("Returned from deck options -> Restarting activity")
@@ -971,7 +993,10 @@ abstract class AbstractFlashcardViewer :
971993
val params = flipCardLayout!!.layoutParams
972994
params.height = initialFlipCardHeight * 2
973995
}
974-
answerField = findViewById(R.id.answer_field)
996+
answerField =
997+
findViewById<FixedEditText>(R.id.answer_field).also { answerField ->
998+
defaultAnswerFieldInputType = answerField.inputType
999+
}
9751000
initControls()
9761001

9771002
// Position answer buttons
@@ -1333,6 +1358,7 @@ abstract class AbstractFlashcardViewer :
13331358
// Show text entry based on if the user wants to write the answer
13341359
answerField?.visibility = View.VISIBLE
13351360
answerField?.applyLanguageHint(typeAnswer?.languageHint)
1361+
applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
13361362
} else {
13371363
answerField?.visibility = View.GONE
13381364
}
@@ -1978,6 +2004,7 @@ abstract class AbstractFlashcardViewer :
19782004
// Show text entry based on if the user wants to write the answer
19792005
answerField?.visibility = View.VISIBLE
19802006
answerField?.applyLanguageHint(typeAnswer?.languageHint)
2007+
applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
19812008
} else {
19822009
answerField?.visibility = View.GONE
19832010
}

AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import com.ichi2.anki.logging.FragmentLifecycleLogger
5151
import com.ichi2.anki.logging.LogType
5252
import com.ichi2.anki.logging.ProductionCrashReportingTree
5353
import com.ichi2.anki.logging.RobolectricDebugTree
54+
import com.ichi2.anki.model.FieldFilters.NoSuggestFilter
5455
import com.ichi2.anki.observability.ChangeManager
5556
import com.ichi2.anki.preferences.SharedPreferencesProvider
5657
import com.ichi2.anki.preferences.sharedPrefs
@@ -267,8 +268,20 @@ open class AnkiDroidApp :
267268

268269
activityAgnosticDialogs = ActivityAgnosticDialogs.register(this)
269270
TtsVoices.launchBuildLocalesJob()
271+
applyCustomFieldFilters()
272+
}
273+
274+
/**
275+
* Applies AnkiDroid-specific implementations of field filters
276+
*
277+
* @see com.ichi2.anki.model.FieldFilter
278+
* @see com.ichi2.anki.model.FieldFilters
279+
*/
280+
private fun applyCustomFieldFilters() {
270281
// enable {{tts-voices:}} field filter
271282
TtsVoicesFieldFilter.ensureApplied()
283+
// enable {{nosuggest:type:}} field filter (issue #10352)
284+
NoSuggestFilter.ensureApplied()
272285
}
273286

274287
/**

AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,4 @@
1-
/*
2-
* Copyright (c) 2021 David Allison <davidallisongithub@gmail.com>
3-
*
4-
* This program is free software; you can redistribute it and/or modify it under
5-
* the terms of the GNU General Public License as published by the Free Software
6-
* Foundation; either version 3 of the License, or (at your option) any later
7-
* version.
8-
*
9-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10-
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11-
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12-
*
13-
* You should have received a copy of the GNU General Public License along with
14-
* this program. If not, see <http://www.gnu.org/licenses/>.
15-
*/
1+
// SPDX-License-Identifier: GPL-3.0-or-later
162

173
package com.ichi2.anki.cardviewer
184

@@ -31,6 +17,8 @@ import java.util.regex.Matcher
3117
import java.util.regex.Pattern
3218

3319
/**
20+
* Handles 'Type the answer' for the old study screen
21+
*
3422
* @param useInputTag use an `<input>` tag to allow for HTML styling
3523
* @param autoFocus Whether the user wants to focus "type in answer"
3624
*/
@@ -45,6 +33,14 @@ class TypeAnswer(
4533
var combining: Boolean = true
4634
private set
4735

36+
/**
37+
* Whether keyboard suggestions, swiping and autocorrect should be disabled (#10352).
38+
*
39+
* @see com.ichi2.anki.model.FieldFilters.NoSuggestFilter
40+
*/
41+
var noSuggest: Boolean = false
42+
private set
43+
4844
/** What the learner actually typed (externally mutable) */
4945
var input = ""
5046

@@ -86,24 +82,18 @@ class TypeAnswer(
8682
res: Resources,
8783
) {
8884
combining = true
85+
noSuggest = false
8986
correct = null
9087
val q = card.question(col)
9188
val m = PATTERN.matcher(q)
92-
var clozeIdx = 0
9389
if (!m.find()) {
9490
return
9591
}
96-
var fldTag = m.group(1)!!
97-
// if it's a cloze, extract data
98-
if (fldTag.startsWith("cloze:")) {
99-
// get field and cloze position
100-
clozeIdx = card.ord + 1
101-
fldTag = fldTag.split(":").toTypedArray()[1]
102-
}
103-
if (fldTag.startsWith("nc:")) {
104-
combining = false
105-
fldTag = fldTag.split(":").toTypedArray()[1]
106-
}
92+
val parsed = TypeAnswerModifiers.parse(m.group(1)!!)
93+
combining = parsed.combining
94+
noSuggest = parsed.noSuggest
95+
val fldTag = parsed.fieldName
96+
val clozeIdx = if (parsed.cloze) card.ord + 1 else 0
10797
// loop through fields for a match
10898
for (fld in card.noteType(col).fields) {
10999
val name = fld.name
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
package com.ichi2.anki.cardviewer
4+
5+
/**
6+
* Represents modifiers for `[[type:...]]`
7+
*
8+
* Examples:
9+
* - `[[type:nosuggest:nc:Field]]`
10+
* - `[[type:cloze:Field]]`
11+
*/
12+
internal data class TypeAnswerModifiers(
13+
val fieldName: String,
14+
val combining: Boolean,
15+
val cloze: Boolean,
16+
val noSuggest: Boolean,
17+
) {
18+
companion object {
19+
/**
20+
* Represents the known modifiers in a [[type: ]] declaration, and remaining data
21+
*/
22+
fun parse(rawField: String): TypeAnswerModifiers {
23+
var remaining = rawField
24+
var combining = true
25+
var cloze = false
26+
var noSuggest = false
27+
while (true) {
28+
when {
29+
remaining.startsWith("nosuggest:") -> {
30+
noSuggest = true
31+
remaining = remaining.removePrefix("nosuggest:")
32+
}
33+
remaining.startsWith("cloze:") -> {
34+
cloze = true
35+
remaining = remaining.removePrefix("cloze:")
36+
}
37+
remaining.startsWith("nc:") -> {
38+
combining = false
39+
remaining = remaining.removePrefix("nc:")
40+
}
41+
else -> return TypeAnswerModifiers(
42+
fieldName = remaining,
43+
combining = combining,
44+
cloze = cloze,
45+
noSuggest = noSuggest,
46+
)
47+
}
48+
}
49+
}
50+
}
51+
}

AnkiDroid/src/main/java/com/ichi2/anki/model/FieldFilters.kt

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
1-
/*
2-
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3-
*
4-
* This program is free software; you can redistribute it and/or modify it under
5-
* the terms of the GNU General Public License as published by the Free Software
6-
* Foundation; either version 3 of the License, or (at your option) any later
7-
* version.
8-
*
9-
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10-
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11-
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12-
*
13-
* You should have received a copy of the GNU General Public License along with
14-
* this program. If not, see <http://www.gnu.org/licenses/>.
15-
*/
1+
// SPDX-License-Identifier: GPL-3.0-or-later
162

173
package com.ichi2.anki.model
184

195
import androidx.annotation.CheckResult
206
import com.ichi2.anki.libanki.NotetypeJson
7+
import com.ichi2.anki.libanki.TemplateManager
218

229
// Classes relating to a chain of field filters: {{type:cloze:Front}}
2310

@@ -34,7 +21,7 @@ import com.ichi2.anki.libanki.NotetypeJson
3421
* only be used to validate inserting new filters in the AnkiDroid editor.
3522
*
3623
* @see FieldFilter
37-
* @see com.ichi2.anki.libanki.TemplateManager.FieldFilter
24+
* @see TemplateManager.FieldFilter
3825
*/
3926
data class FilterContext(
4027
/**
@@ -69,6 +56,10 @@ data class FilterContext(
6956
* Applying more filters to these outputs would corrupt them.
7057
*/
7158
val isTerminal: Boolean = false,
59+
/**
60+
* Whether the chain produces a `[[type:...]]` marker (via the `type:` field filter)
61+
*/
62+
val producesTypeMarker: Boolean = false,
7263
)
7364

7465
/**
@@ -133,6 +124,7 @@ object FieldFilters {
133124
HintFilter,
134125
TypeTheAnswerFilter,
135126
TypeTheAnswerNonCombiningFilter,
127+
NoSuggestFilter,
136128
TextToSpeechFilter(),
137129
FuriganaFilter,
138130
KanaFilter,
@@ -240,7 +232,7 @@ object FieldFilters {
240232
object TypeTheAnswerFilter : FieldFilter {
241233
override val name: String = "type"
242234

243-
override fun updateContext(input: FilterContext) = input.copy(isTerminal = true)
235+
override fun updateContext(input: FilterContext) = input.copy(isTerminal = true, producesTypeMarker = true)
244236
}
245237

246238
/**
@@ -263,7 +255,49 @@ object FieldFilters {
263255
object TypeTheAnswerNonCombiningFilter : FieldFilter {
264256
override val name: String = "type:nc"
265257

266-
override fun updateContext(input: FilterContext) = input.copy(isTerminal = true)
258+
override fun updateContext(input: FilterContext) = input.copy(isTerminal = true, producesTypeMarker = true)
259+
}
260+
261+
/**
262+
* `{{nosuggest:type:Field}}`: disables keyboard suggestions, swiping and autocorrect
263+
*
264+
* AnkiDroid-only.
265+
*
266+
* Converts the rendered `[[type:abc]]` to `[[type:nosuggest:abc]]`, which is then consumed
267+
* by the `TypeAnswer` parser.
268+
*
269+
* See [#10352](https://github.com/ankidroid/Anki-Android/issues/10352).
270+
* @see com.ichi2.anki.previewer.TypeAnswer.noSuggest
271+
*/
272+
object NoSuggestFilter :
273+
FieldFilter,
274+
TemplateManager.FieldFilter {
275+
override val name: String = "nosuggest"
276+
277+
/**
278+
* May only be applied to `type:` or `type:nc` filter which are both terminal chains,
279+
* therefore, do not check 'super.canApplyTo'
280+
*/
281+
override fun canApplyTo(context: FilterContext): Boolean = context.producesTypeMarker
282+
283+
override fun updateContext(input: FilterContext): FilterContext = input
284+
285+
override fun apply(
286+
fieldText: String,
287+
fieldName: String,
288+
filterName: String,
289+
ctx: TemplateManager.TemplateRenderContext,
290+
): String {
291+
if (filterName != name) return fieldText
292+
if (!fieldText.contains("[[type:")) return fieldText
293+
if (fieldText.contains("[[type:nosuggest:")) return fieldText
294+
return fieldText.replace("[[type:", "[[type:nosuggest:")
295+
}
296+
297+
/** Registers this filter as a runtime transformer with the [TemplateManager]. */
298+
fun ensureApplied() {
299+
TemplateManager.fieldFilters.putIfAbsent(name, this)
300+
}
267301
}
268302

269303
/**

0 commit comments

Comments
 (0)