From fa8e23d373813ce7bba8146e1a9b76623a7f1e8f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:00:17 +0700 Subject: [PATCH 1/5] refactor: add InsertFieldDialogViewModel I will be extending this class to handle field filters and special fields * rendering the output is now the responsibility of `renderToTemplateTag()` rather than done in the CardTemplateEditor --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 6 +- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 44 +++++++---- .../dialogs/InsertFieldDialogViewModel.kt | 76 +++++++++++++++++++ .../java/com/ichi2/anki/model/FieldName.kt | 29 +++++++ .../com/ichi2/anki/CardTemplateEditorTest.kt | 2 +- .../dialogs/InsertFieldDialogViewModelTest.kt | 71 +++++++++++++++++ 6 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 85f29417227b..b2a863959c1c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -802,13 +802,11 @@ open class CardTemplateEditor : } } - @Suppress("unused") - private fun insertField(fieldName: String) { + private fun insertField(fieldToInsert: String) { val start = max(binding.editText.selectionStart, 0) val end = max(binding.editText.selectionEnd, 0) // add string to editText - val updatedString = "{{$fieldName}}" - binding.editText.text!!.replace(min(start, end), max(start, end), updatedString, 0, updatedString.length) + binding.editText.text!!.replace(min(start, end), max(start, end), fieldToInsert, 0, fieldToInsert.length) } fun setCurrentEditorView( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 4f323784eefd..05850466df38 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -17,14 +17,19 @@ package com.ichi2.anki.dialogs import android.os.Bundle +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY +import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create import com.ichi2.utils.customListAdapter import com.ichi2.utils.negativeButton @@ -37,7 +42,7 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { - private lateinit var fieldList: List + private val viewModel by viewModels() private lateinit var requestKey: String /** @@ -45,7 +50,6 @@ class InsertFieldDialog : DialogFragment() { */ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) - fieldList = requireArguments().getStringArrayList(KEY_FIELD_ITEMS)!! requestKey = requireArguments().getString(KEY_REQUEST_KEY)!! val adapter: RecyclerView.Adapter<*> = object : RecyclerView.Adapter() { @@ -62,11 +66,12 @@ class InsertFieldDialog : DialogFragment() { position: Int, ) { val textView = holder.itemView as TextView - textView.text = fieldList[position] - textView.setOnClickListener { selectFieldAndClose(textView) } + val field = viewModel.fieldNames[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectNamedField(field) } } - override fun getItemCount(): Int = fieldList.size + override fun getItemCount(): Int = viewModel.fieldNames.size } return AlertDialog.Builder(requireContext()).create { title(R.string.card_template_editor_select_field) @@ -75,21 +80,32 @@ class InsertFieldDialog : DialogFragment() { } } - private fun selectFieldAndClose(textView: TextView) { - parentFragmentManager.setFragmentResult( - requestKey, - bundleOf(KEY_INSERTED_FIELD to textView.text.toString()), - ) - dismiss() + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + // setup flows + launchCatchingTask { + viewModel.selectedFieldFlow.collect { field -> + if (field == null) return@collect + parentFragmentManager.setFragmentResult( + requestKey, + bundleOf(KEY_INSERTED_FIELD to field.renderToTemplateTag()), + ) + dismiss() + } + } } companion object { /** - * This fragment requires that a list of fields names to be passed in. + * A key in the extras of the Fragment Result + * + * Represents the template tag for the selected field: `{{Front}}` */ const val KEY_INSERTED_FIELD = "key_inserted_field" - private const val KEY_FIELD_ITEMS = "key_field_items" - private const val KEY_REQUEST_KEY = "key_request_key" /** * Creates a new instance of [InsertFieldDialog] diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt new file mode 100644 index 000000000000..53941b3e9047 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import androidx.annotation.CheckResult +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ichi2.anki.model.FieldName +import com.ichi2.anki.utils.ext.require +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * ViewModel for [InsertFieldDialog] + * + * Handles availability of fields + */ +class InsertFieldDialogViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + /** The field names of the note type */ + val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) + + val selectedFieldFlow = MutableStateFlow(null) + + /** + * Select a named field defined on the note type + */ + fun selectNamedField(fieldName: FieldName) { + if (!fieldNames.contains(fieldName)) return + selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) + } + + sealed class SelectedField { + /** + * A field defined on the note type + * + * e.g `Front` + */ + class NoteTypeField( + val name: FieldName, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{$name}}" + + companion object { + fun from(fieldName: FieldName) = NoteTypeField(fieldName) + } + } + + /** + * Renders the field for use in the Card Template + * + * Example: `{{type:Front}}` + */ + @CheckResult + abstract fun renderToTemplateTag(): String + } + + companion object { + const val KEY_FIELD_ITEMS = "key_field_items" + const val KEY_REQUEST_KEY = "key_request_key" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt new file mode 100644 index 000000000000..9c6aa3c6caed --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.model + +/** + * The name of a Note Type's field + * + * example: `Front` + */ +@JvmInline +value class FieldName( + val name: String, +) { + override fun toString() = name +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index 9717734063b8..7a2dcb9b24c5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -823,7 +823,7 @@ class CardTemplateEditorTest : RobolectricTest() { advanceRobolectricLooper() val resultBundle = Bundle() - resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, fieldToInsert) + resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, expectedFieldText) testEditor.supportFragmentManager.setFragmentResult(firstFragmentAgain.insertFieldRequestKey, resultBundle) advanceRobolectricLooper() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt new file mode 100644 index 000000000000..8b058808f1eb --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import androidx.lifecycle.SavedStateHandle +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField.NoteTypeField +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.jupiter.api.assertInstanceOf +import kotlin.test.assertNotNull + +/** + * Test for [InsertFieldDialogViewModel] + */ +class InsertFieldDialogViewModelTest { + @Test + fun `expected fields are exposed`() = + withViewModel { + assertThat( + "Note type fields are copied", + fieldNames.map { it.name }, + equalTo(listOf("Front", "Back")), + ) + } + + @Test + fun `field selection emits data`() = + withViewModel { + assertThat(selectedFieldFlow.value, nullValue()) + + selectNamedField(fieldNames[0]) + + val selectedField = assertNotNull(selectedFieldFlow.value) + val field = assertInstanceOf(selectedField) + assertThat(field.renderToTemplateTag(), equalTo("{{Front}}")) + } + + fun withViewModel( + fieldList: List = listOf("Front", "Back"), + block: InsertFieldDialogViewModel.() -> Unit, + ) { + val savedStateHandle = + SavedStateHandle().apply { + this[InsertFieldDialogViewModel.KEY_FIELD_ITEMS] = ArrayList(fieldList) + } + withViewModel(savedStateHandle, block) + } + + fun withViewModel( + savedStateHandle: SavedStateHandle, + block: InsertFieldDialogViewModel.() -> Unit, + ) { + InsertFieldDialogViewModel(savedStateHandle).run(block) + } +} From 45473ffd771d8dbd48719efc0a38265a886f5462 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:22:37 +0700 Subject: [PATCH 2/5] feat(insert-field): backend for 'Special Fields' This introduces Anki's 'Special Fields' in the ViewModel and adds a 'side' argument, so `{{FrontSide}}` can optionally be displayed This does not implement the user interface See: https://docs.ankiweb.net/templates/fields.html#special-fields --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 37 +++++- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 3 + .../dialogs/InsertFieldDialogViewModel.kt | 35 ++++++ .../java/com/ichi2/anki/model/SpecialField.kt | 115 ++++++++++++++++++ .../dialogs/InsertFieldDialogViewModelTest.kt | 65 ++++++++++ 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index b2a863959c1c..c474bf758285 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -62,6 +62,7 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut +import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.databinding.CardTemplateEditorBinding @@ -73,6 +74,7 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener import com.ichi2.anki.dialogs.DiscardChangesDialog import com.ichi2.anki.dialogs.InsertFieldDialog +import com.ichi2.anki.dialogs.InsertFieldMetadata import com.ichi2.anki.libanki.CardOrdinal import com.ichi2.anki.libanki.CardTemplates import com.ichi2.anki.libanki.Collection @@ -540,6 +542,15 @@ open class CardTemplateEditor : var currentEditorViewId = 0 + private val currentEditTab: EditTab? + get() = + when (currentEditorViewId) { + R.id.front_edit -> EditTab.FRONT + R.id.back_edit -> EditTab.BACK + R.id.styling_edit -> EditTab.STYLING + else -> null + } + private lateinit var templateEditor: CardTemplateEditor lateinit var tempModel: CardTemplateNotetype @@ -646,9 +657,10 @@ open class CardTemplateEditor : override fun afterTextChanged(arg0: Editable) { refreshFragmentRunnable?.let { refreshFragmentHandler.removeCallbacks(it) } - when (currentEditorViewId) { - R.id.styling_edit -> tempModel.css = binding.editText.text.toString() - R.id.back_edit -> template.afmt = binding.editText.text.toString() + when (currentEditTab) { + EditTab.STYLING -> tempModel.css = binding.editText.text.toString() + EditTab.BACK -> template.afmt = binding.editText.text.toString() + EditTab.FRONT -> template.qfmt = binding.editText.text.toString() else -> template.qfmt = binding.editText.text.toString() } templateEditor.tempNoteType!!.updateTemplate(cardIndex, template) @@ -757,7 +769,18 @@ open class CardTemplateEditor : ) fun showInsertFieldDialog() { templateEditor.fieldNames?.let { fieldNames -> - val dialog = InsertFieldDialog.newInstance(fieldNames, insertFieldRequestKey) + val side = + when (currentEditTab) { + EditTab.FRONT -> SingleCardSide.FRONT + EditTab.BACK -> SingleCardSide.BACK + else -> SingleCardSide.FRONT + } + val dialog = + InsertFieldDialog.newInstance( + fieldItems = fieldNames, + metadata = InsertFieldMetadata(side = side), + requestKey = insertFieldRequestKey, + ) templateEditor.showDialogFragment(dialog) } } @@ -1507,6 +1530,12 @@ open class CardTemplateEditor : } } + enum class EditTab { + FRONT, + BACK, + STYLING, + } + companion object { private const val TAB_TO_CURSOR_POSITION_KEY = "tabToCursorPosition" private const val EDITOR_VIEW_ID_KEY = "editorViewId" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 05850466df38..3225aa998bda 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -28,6 +28,7 @@ import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create @@ -116,12 +117,14 @@ class InsertFieldDialog : DialogFragment() { */ fun newInstance( fieldItems: List, + metadata: InsertFieldMetadata, requestKey: String, ): InsertFieldDialog = InsertFieldDialog().apply { arguments = bundleOf( KEY_FIELD_ITEMS to ArrayList(fieldItems), + KEY_INSERT_FIELD_METADATA to metadata, KEY_REQUEST_KEY to requestKey, ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index 53941b3e9047..b98b31fe7779 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -16,12 +16,18 @@ package com.ichi2.anki.dialogs +import android.os.Parcelable import androidx.annotation.CheckResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.model.FieldName +import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.require import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.parcelize.Parcelize + +private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField /** * ViewModel for [InsertFieldDialog] @@ -34,8 +40,17 @@ class InsertFieldDialogViewModel( /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) + private val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) + val selectedFieldFlow = MutableStateFlow(null) + /** + * An ordered list of special fields which may be used + * + * @see com.ichi2.anki.model.SpecialField + */ + val specialFields = SpecialFields.all(side = metadata.side) + /** * Select a named field defined on the note type */ @@ -44,6 +59,14 @@ class InsertFieldDialogViewModel( selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) } + /** + * Select a usable special field + */ + fun selectSpecialField(field: SpecialFieldModel) { + if (!specialFields.contains(field)) return + selectedFieldFlow.value = SelectedField.SpecialField(model = field) + } + sealed class SelectedField { /** * A field defined on the note type @@ -60,6 +83,12 @@ class InsertFieldDialogViewModel( } } + class SpecialField( + val model: SpecialFieldModel, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{${model.name}}}" + } + /** * Renders the field for use in the Card Template * @@ -71,6 +100,12 @@ class InsertFieldDialogViewModel( companion object { const val KEY_FIELD_ITEMS = "key_field_items" + const val KEY_INSERT_FIELD_METADATA = "key_field_options" const val KEY_REQUEST_KEY = "key_request_key" } } + +@Parcelize +data class InsertFieldMetadata( + val side: SingleCardSide, +) : Parcelable diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt new file mode 100644 index 000000000000..4b5ba89e608a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.model + +import androidx.annotation.VisibleForTesting +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.SingleCardSide.FRONT + +/** + * Special fields allow a card template to use properties of the current card/note + * + * Example: `{{Subdeck}}` displays the deck name + * + * - [Anki Manual: Special fields](https://docs.ankiweb.net/templates/fields.html#special-fields) + * - [Source (permalink)](https://github.com/ankitects/anki/blob/8f2144534bff6efedb22b7f052fba13ffe28cbc2/rslib/src/notetype/mod.rs#L70-L82) + */ +@JvmInline +value class SpecialField( + val name: String, +) + +/** @see SpecialField */ +object SpecialFields { + /** + * The content of the front template. Only valid on the back template + * + * `FrontSide` does not automatically play any audio that was on the front side of the card + */ + val FrontSide = SpecialField("FrontSide") + + /** + * The name of the card template (`Card 1`) + */ + val CardTemplate = SpecialField("Card") + + /** + * The card's flag, including its integer code. + * + * `flagN` where N : + * * 0 - unset + * * 1 - RED etc... + * + * @see com.ichi2.anki.Flag.code + */ + val Flag = SpecialField("CardFlag") + + /** + * The full tree of the card's deck + * + * `A::B:C` + */ + val Deck = SpecialField("Deck") + + /** + * The card's subdeck + * + * `C`, if the card is in deck: `A::B:C` + */ + val Subdeck = SpecialField("Subdeck") + + /** + * The note's tags + * + * space-delimited: `tag1 tag2` + */ + val Tags = SpecialField("Tags") + + /** + * The name of the note type + * + * example: `Basic` + */ + val NoteType = SpecialField("Type") + + /** @see com.ichi2.anki.libanki.CardId */ + val CardId = SpecialField("CardID") + + @VisibleForTesting + internal val ALL = + listOf( + FrontSide, + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + + /** + * Returns all available special fields in an order suitable for displaying to a user + */ + fun all(side: SingleCardSide) = + ALL.filter { field -> + when { + field == FrontSide && side == FRONT -> false + else -> true + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt index 8b058808f1eb..4bcb2576828e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -17,7 +17,12 @@ package com.ichi2.anki.dialogs import androidx.lifecycle.SavedStateHandle +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.SingleCardSide.BACK +import com.ichi2.anki.cardviewer.SingleCardSide.FRONT +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField.NoteTypeField +import com.ichi2.anki.model.SpecialFields import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat @@ -51,13 +56,73 @@ class InsertFieldDialogViewModelTest { assertThat(field.renderToTemplateTag(), equalTo("{{Front}}")) } + @Test + fun `special field ordering (Front)`() = + withViewModel(side = FRONT) { + assertThat( + this.specialFields, + equalTo( + with(SpecialFields) { + listOf( + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + }, + ), + ) + } + + @Test + fun `special field ordering (Back)`() = + withViewModel(side = BACK) { + assertThat( + this.specialFields, + equalTo( + with(SpecialFields) { + listOf( + FrontSide, + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + }, + ), + ) + } + + @Test + fun `special field selection emits data`() = + withViewModel { + assertThat(selectedFieldFlow.value, nullValue()) + + selectSpecialField(SpecialFields.Deck) + + val selectedField = assertNotNull(selectedFieldFlow.value) + val field = assertInstanceOf(selectedField) + assertThat(field.renderToTemplateTag(), equalTo("{{Deck}}")) + } + fun withViewModel( fieldList: List = listOf("Front", "Back"), + side: SingleCardSide = FRONT, block: InsertFieldDialogViewModel.() -> Unit, ) { val savedStateHandle = SavedStateHandle().apply { this[InsertFieldDialogViewModel.KEY_FIELD_ITEMS] = ArrayList(fieldList) + this[InsertFieldDialogViewModel.KEY_INSERT_FIELD_METADATA] = + InsertFieldMetadata( + side = side, + ) } withViewModel(savedStateHandle, block) } From 10535676ef0be4b5c7d823b948b784a4ebc104e9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:32:31 +0700 Subject: [PATCH 3/5] feat(insert-field): insert special fields Improves discoverability of this feature vs needing to go through the manual Assisted-by: GPT-5.2: ViewPager2.updateHeight --- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 210 +++++++++++++++--- .../dialogs/InsertFieldDialogViewModel.kt | 19 ++ .../ichi2/anki/utils/ext/MutableStateFlow.kt | 47 ++++ .../main/res/layout/dialog_insert_field.xml | 55 +++++ AnkiDroid/src/main/res/values/03-dialogs.xml | 4 + 5 files changed, 301 insertions(+), 34 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/MutableStateFlow.kt create mode 100644 AnkiDroid/src/main/res/layout/dialog_insert_field.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 3225aa998bda..44b7e365f776 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -18,23 +18,35 @@ package com.ichi2.anki.dialogs import android.os.Bundle import android.view.View +import android.view.View.MeasureSpec import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R +import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding +import com.ichi2.anki.databinding.DialogInsertFieldBinding import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create -import com.ichi2.utils.customListAdapter import com.ichi2.utils.negativeButton import com.ichi2.utils.title +import dev.androidbroadcast.vbpd.viewBinding /** * Dialog fragment used to show the fields that the user can insert in the card editor. This @@ -43,49 +55,55 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { + private lateinit var binding: DialogInsertFieldBinding private val viewModel by viewModels() - private lateinit var requestKey: String + private val requestKey + get() = + requireNotNull(requireArguments().getString(KEY_REQUEST_KEY)) { + KEY_REQUEST_KEY + } /** * A dialog for inserting field in card template editor */ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) - requestKey = requireArguments().getString(KEY_REQUEST_KEY)!! - val adapter: RecyclerView.Adapter<*> = - object : RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) - return object : RecyclerView.ViewHolder(root) {} - } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - val textView = holder.itemView as TextView - val field = viewModel.fieldNames[position] - textView.text = field.name - textView.setOnClickListener { viewModel.selectNamedField(field) } - } - - override fun getItemCount(): Int = viewModel.fieldNames.size + binding = DialogInsertFieldBinding.inflate(layoutInflater) + val dialog = + AlertDialog.Builder(requireContext()).create { + title(R.string.card_template_editor_select_field) + negativeButton(R.string.dialog_cancel) + setView(binding.root) } - return AlertDialog.Builder(requireContext()).create { - title(R.string.card_template_editor_select_field) - negativeButton(R.string.dialog_cancel) - customListAdapter(adapter) - } - } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) + binding.viewPager.adapter = InsertFieldDialogAdapter(this) + TabLayoutMediator( + binding.tabLayout, + binding.viewPager, + ) { tab: TabLayout.Tab, position: Int -> + val entry = + Tab.entries + .first { it.position == position } + + tab.text = entry.title + }.attach() + binding.tabLayout.selectTab(binding.tabLayout.getTabAt(viewModel.currentTab.position)) + + binding.viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + Tab.entries + .first { it.position == position } + .let { selectedTab -> + viewModel.currentTab = selectedTab + } + super.onPageSelected(position) + + binding.viewPager.updateHeight(childFragmentManager) + } + }, + ) // setup flows launchCatchingTask { @@ -98,6 +116,8 @@ class InsertFieldDialog : DialogFragment() { dismiss() } } + + return dialog } companion object { @@ -129,4 +149,126 @@ class InsertFieldDialog : DialogFragment() { ) } } + + class InsertFieldDialogAdapter( + fragment: Fragment, + ) : FragmentStateAdapter(fragment) { + override fun createFragment(position: Int): Fragment = + when (position) { + 0 -> SelectBasicFieldFragment() + 1 -> SelectSpecialFieldFragment() + else -> throw IllegalStateException("invalid position: $position") + } + + override fun getItemCount() = 2 + } + + class SelectBasicFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { + val viewModel by viewModels( + ownerProducer = { requireParentFragment() as InsertFieldDialog }, + ) + val binding by viewBinding(DialogGenericRecyclerViewBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.root.layoutManager = LinearLayoutManager(context) + binding.root.adapter = + object : RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) + return object : RecyclerView.ViewHolder(root) {} + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + val textView = holder.itemView as TextView + val field = viewModel.fieldNames[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectNamedField(field) } + } + + override fun getItemCount(): Int = viewModel.fieldNames.size + } + } + } + + class SelectSpecialFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { + val viewModel by viewModels( + ownerProducer = { requireParentFragment() as InsertFieldDialog }, + ) + val binding by viewBinding(DialogGenericRecyclerViewBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.root.adapter = + object : RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) + return object : RecyclerView.ViewHolder(root) {} + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + val textView = holder.itemView as TextView + val field = viewModel.specialFields[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectSpecialField(field) } + } + + override fun getItemCount(): Int = viewModel.specialFields.size + } + binding.root.layoutManager = LinearLayoutManager(context) + } + } } + +fun ViewPager2.updateHeight(fragmentManager: FragmentManager) { + fun getCurrentFragment(fragmentManager: FragmentManager): Fragment? { + val currentTag = "f$currentItem" + return fragmentManager.findFragmentByTag(currentTag) + } + + post { + val fragment = getCurrentFragment(fragmentManager) ?: return@post + val recyclerView = fragment.view as? RecyclerView ?: return@post + + // Measure RecyclerView height + recyclerView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + ) + + // Update ViewPager height + layoutParams.height = recyclerView.measuredHeight + requestLayout() + } +} + +context(dialog: InsertFieldDialog) +private val Tab.title: String + @StringRes + get() = + dialog.requireContext().getString( + when (this) { + Tab.FIELDS -> R.string.standard_fields_tab_header + Tab.SPECIAL -> R.string.special_fields_tab_header + }, + ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index b98b31fe7779..ab9bab681fa5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -23,9 +23,11 @@ import androidx.lifecycle.ViewModel import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.model.FieldName import com.ichi2.anki.model.SpecialFields +import com.ichi2.anki.utils.ext.asVar import com.ichi2.anki.utils.ext.require import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.parcelize.Parcelize +import timber.log.Timber private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField @@ -37,6 +39,11 @@ private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField class InsertFieldDialogViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { + var currentTabFlow: MutableStateFlow = + savedStateHandle.getMutableStateFlow(STATE_TAB, Tab.FIELDS) + + var currentTab by currentTabFlow.asVar() + /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) @@ -55,6 +62,7 @@ class InsertFieldDialogViewModel( * Select a named field defined on the note type */ fun selectNamedField(fieldName: FieldName) { + Timber.i("selected named field") if (!fieldNames.contains(fieldName)) return selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) } @@ -63,6 +71,7 @@ class InsertFieldDialogViewModel( * Select a usable special field */ fun selectSpecialField(field: SpecialFieldModel) { + Timber.i("selected special field: %s", field.name) if (!specialFields.contains(field)) return selectedFieldFlow.value = SelectedField.SpecialField(model = field) } @@ -98,10 +107,20 @@ class InsertFieldDialogViewModel( abstract fun renderToTemplateTag(): String } + @Parcelize + enum class Tab( + val position: Int, + ) : Parcelable { + FIELDS(0), + SPECIAL(1), + } + companion object { const val KEY_FIELD_ITEMS = "key_field_items" const val KEY_INSERT_FIELD_METADATA = "key_field_options" const val KEY_REQUEST_KEY = "key_request_key" + + const val STATE_TAB = "state_tab" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/MutableStateFlow.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/MutableStateFlow.kt new file mode 100644 index 000000000000..9de02c5f2388 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/MutableStateFlow.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.utils.ext + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.reflect.KProperty + +/** + * Syntactic sugar to expose a [MutableStateFlow] as a `var` + * + * ```kotlin + * var flow = savedStateHandle.getMutableStateFlow("tag", "default") + * var prop by flow.asVar() + * ``` + */ +fun MutableStateFlow.asVar(): StateFlowVarDelegate = StateFlowVarDelegate(this) + +class StateFlowVarDelegate( + private val flow: MutableStateFlow, +) { + operator fun getValue( + thisRef: Any?, + property: KProperty<*>, + ): T = flow.value + + operator fun setValue( + thisRef: Any?, + property: KProperty<*>, + value: T, + ) { + flow.value = value + } +} diff --git a/AnkiDroid/src/main/res/layout/dialog_insert_field.xml b/AnkiDroid/src/main/res/layout/dialog_insert_field.xml new file mode 100644 index 000000000000..65f410a10baa --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_insert_field.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index 8348a8795762..0d84bc7bb489 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -286,4 +286,8 @@ also changes the interval of the card" If changing to a regular note type, and there are more cloze deletions than available card templates, any extra cards will be removed. Please select notes from only one notetype. No changes to save + + + Fields + Special From 5449f2c733764d434bc042eb468ef9ddc877a8f9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:30:08 +0700 Subject: [PATCH 4/5] refactor(template-editor): add 'ord' helper --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index c474bf758285..3db7ca2f8ce3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -157,6 +157,14 @@ open class CardTemplateEditor : private var tabToViewId: HashMap = HashMap() private var startingOrdId: CardOrdinal = 0 + /** + * The ordinal of the current template being edited + * + * Valid for use in [tempNoteType] + */ + private val ord: Int + get() = mainBinding.cardTemplateEditorPager.currentItem + /** * If true, the view is split in two. The template editor appears on the leading side and the previewer on the trailing side. * This occurs when the screen size is large @@ -373,8 +381,7 @@ open class CardTemplateEditor : return } - val ordinal = mainBinding.cardTemplateEditorPager.currentItem - val template = tempNoteType!!.getTemplate(ordinal) + val template = tempNoteType!!.getTemplate(ord) val templateName = template.name if (deck != null && getColUnsafe.decks.isFiltered(deck.deckId)) { @@ -469,7 +476,7 @@ open class CardTemplateEditor : val currentFragment: CardTemplateFragment? get() = try { - supportFragmentManager.findFragmentByTag("f" + mainBinding.cardTemplateEditorPager.currentItem) as CardTemplateFragment? + supportFragmentManager.findFragmentByTag("f" + ord) as CardTemplateFragment? } catch (e: Exception) { Timber.w("Failed to get current fragment") null @@ -794,7 +801,7 @@ open class CardTemplateEditor : Timber.w("attempted to rename a dynamic note type") return } - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val ordinal = templateEditor.ord val template = templateEditor.tempNoteType!!.getTemplate(ordinal) RenameCardTemplateDialog.showInstance( @@ -819,7 +826,7 @@ open class CardTemplateEditor : templateEditor.mainBinding.cardTemplateEditorPager.adapter!! .itemCount, ) { newPosition -> - val currentPosition = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val currentPosition = templateEditor.ord Timber.w("moving card template %d to %d", currentPosition, newPosition) TODO("CardTemplateNotetype is a complex class and requires significant testing") } @@ -885,7 +892,7 @@ open class CardTemplateEditor : fun deleteCardTemplate() { templateEditor.lifecycleScope.launch { val tempModel = templateEditor.tempNoteType - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val ordinal = templateEditor.ord val template = tempModel!!.getTemplate(ordinal) // Don't do anything if only one template if (tempModel.templateCount < 2) { @@ -936,12 +943,11 @@ open class CardTemplateEditor : return } // Show confirmation dialog - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem // isOrdinalPendingAdd method will check if there are any new card types added or not, // if TempModel has new card type then numAffectedCards will be 0 by default. val numAffectedCards = - if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, ordinal)) { - templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, ordinal) + if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, templateEditor.ord)) { + templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, templateEditor.ord) } else { 0 } @@ -1138,9 +1144,7 @@ open class CardTemplateEditor : try { val tempModel = templateEditor.tempNoteType val template: BackendCardTemplate = - tempModel!!.getTemplate( - templateEditor.mainBinding.cardTemplateEditorPager.currentItem, - ) + tempModel!!.getTemplate(templateEditor.ord) CardTemplate( front = template.qfmt, back = template.afmt, @@ -1183,13 +1187,12 @@ open class CardTemplateEditor : launchCatchingTask { val notetype = templateEditor.tempNoteType!!.notetype val notetypeFile = NotetypeFile(requireContext(), notetype) - val ord = templateEditor.mainBinding.cardTemplateEditorPager.currentItem val note = withCol { getNote(this) ?: Note.fromNotetypeId(this@withCol, notetype.id) } val args = TemplatePreviewerArguments( notetypeFile = notetypeFile, id = note.id, - ord = ord, + ord = templateEditor.ord, fields = note.fields, tags = note.tags, fillEmpty = true, @@ -1218,8 +1221,7 @@ open class CardTemplateEditor : private fun getCurrentTemplateName(tempModel: CardTemplateNotetype): String = try { - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem - val template = tempModel.getTemplate(ordinal) + val template = tempModel.getTemplate(templateEditor.ord) template.name } catch (e: Exception) { Timber.w(e, "Failed to get name for template") From 72ef8352ab98292099834b75010d9dcd9e7bb2ab Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:13:25 +0700 Subject: [PATCH 5/5] feat(insert-field): add description for special fields This provides an explanation of all Special Fields to the user in the 'Insert field' dialog This also produces contextual help for all fields based on the context of the selected card/note (if any) Assisted-by: GPT-5.2 - learning about BreakIterator --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 32 ++- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 112 ++++++++--- .../dialogs/InsertFieldDialogViewModel.kt | 73 ++++++- ...log_insert_special_field_recycler_item.xml | 51 +++++ AnkiDroid/src/main/res/values/03-dialogs.xml | 9 + .../anki/dialogs/InsertFieldDialogTest.kt | 187 ++++++++++++++++++ .../dialogs/InsertFieldDialogViewModelTest.kt | 6 + common/build.gradle.kts | 1 + .../ichi2/anki/common/utils/StringUtils.kt | 42 +++- .../anki/common/utils/StringUtilsTest.kt | 41 ++++ 10 files changed, 520 insertions(+), 34 deletions(-) create mode 100644 AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 3db7ca2f8ce3..cb2160320509 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -544,6 +544,9 @@ open class CardTemplateEditor : private val cardIndex get() = requireArguments().getInt(CARD_INDEX) + private val templateName + get() = tempModel.notetype.templates[cardIndex].name + val insertFieldRequestKey get() = "request_field_insert_$cardIndex" @@ -775,17 +778,42 @@ open class CardTemplateEditor : "the kotlin migration made this method crash due to a recursive call when the dialog would return its data", ) fun showInsertFieldDialog() { - templateEditor.fieldNames?.let { fieldNames -> + launchCatchingTask { + val fieldNames = templateEditor.fieldNames ?: return@launchCatchingTask + val side = when (currentEditTab) { EditTab.FRONT -> SingleCardSide.FRONT EditTab.BACK -> SingleCardSide.BACK else -> SingleCardSide.FRONT } + + val noteId = if (templateEditor.noteId > 0) templateEditor.noteId else null + + // use the ord of the selected template, not the ord of the currently edited card + + val ord = + // deletions change ordinals, don't try to preview metadata if this occurs. + if (tempModel.templateChanges.any { + it.type == CardTemplateNotetype.ChangeType.DELETE + } + ) { + null + } else { + templateEditor.ord + } + val dialog = InsertFieldDialog.newInstance( fieldItems = fieldNames, - metadata = InsertFieldMetadata(side = side), + metadata = + InsertFieldMetadata.query( + side = side, + noteId = noteId, + ord = ord, + cardTemplateName = templateName, + noteTypeName = tempModel.notetype.name, + ), requestKey = insertFieldRequestKey, ) templateEditor.showDialogFragment(dialog) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 44b7e365f776..09b34d3af49f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -16,17 +16,21 @@ package com.ichi2.anki.dialogs +import android.content.Context import android.os.Bundle +import android.text.Spanned +import android.view.LayoutInflater import android.view.View -import android.view.View.MeasureSpec import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.CheckResult import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf +import androidx.core.text.HtmlCompat +import androidx.core.text.parseAsHtml import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -35,18 +39,23 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.ichi2.anki.CardTemplateEditor +import com.ichi2.anki.Flag import com.ichi2.anki.R import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding import com.ichi2.anki.databinding.DialogInsertFieldBinding +import com.ichi2.anki.databinding.DialogInsertSpecialFieldRecyclerItemBinding import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.model.SpecialField +import com.ichi2.anki.model.SpecialFields import com.ichi2.utils.create import com.ichi2.utils.negativeButton import com.ichi2.utils.title import dev.androidbroadcast.vbpd.viewBinding +import org.jetbrains.annotations.VisibleForTesting /** * Dialog fragment used to show the fields that the user can insert in the card editor. This @@ -99,8 +108,6 @@ class InsertFieldDialog : DialogFragment() { viewModel.currentTab = selectedTab } super.onPageSelected(position) - - binding.viewPager.updateHeight(childFragmentManager) } }, ) @@ -199,6 +206,11 @@ class InsertFieldDialog : DialogFragment() { override fun getItemCount(): Int = viewModel.fieldNames.size } } + + override fun onResume() { + super.onResume() + this.requireView().post { requireView().requestLayout() } + } } class SelectSpecialFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { @@ -214,52 +226,94 @@ class InsertFieldDialog : DialogFragment() { super.onViewCreated(view, savedInstanceState) binding.root.adapter = - object : RecyclerView.Adapter() { + object : RecyclerView.Adapter() { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ): RecyclerView.ViewHolder { - val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) - return object : RecyclerView.ViewHolder(root) {} - } + ) = InsertFieldViewHolder( + DialogInsertSpecialFieldRecyclerItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, + holder: InsertFieldViewHolder, position: Int, ) { - val textView = holder.itemView as TextView val field = viewModel.specialFields[position] - textView.text = field.name - textView.setOnClickListener { viewModel.selectSpecialField(field) } + + holder.binding.title.text = "{{${field.name}}}" + holder.binding.description.text = field.buildDescription(requireContext(), viewModel.metadata) + holder.binding.root.setOnClickListener { viewModel.selectSpecialField(field) } } override fun getItemCount(): Int = viewModel.specialFields.size } binding.root.layoutManager = LinearLayoutManager(context) } + + override fun onResume() { + super.onResume() + // update the height of the ViewPager + this.requireView().post { requireView().requestLayout() } + } } + + private class InsertFieldViewHolder( + val binding: DialogInsertSpecialFieldRecyclerItemBinding, + ) : RecyclerView.ViewHolder(binding.root) } -fun ViewPager2.updateHeight(fragmentManager: FragmentManager) { - fun getCurrentFragment(fragmentManager: FragmentManager): Fragment? { - val currentTag = "f$currentItem" - return fragmentManager.findFragmentByTag(currentTag) +@VisibleForTesting +@CheckResult +fun SpecialField.buildDescription( + context: Context, + metadata: InsertFieldMetadata, +): Spanned { + fun buildSuffix(value: String?): String { + if (value == null) return "" + return context.getString(R.string.special_field_example_suffix, value) } + return when (this) { + SpecialFields.FrontSide -> context.getString(R.string.special_field_front_side_help) + SpecialFields.Deck -> + context.getString(R.string.special_field_deck_help, buildSuffix(metadata.deck)) - post { - val fragment = getCurrentFragment(fragmentManager) ?: return@post - val recyclerView = fragment.view as? RecyclerView ?: return@post + SpecialFields.Subdeck -> + context.getString(R.string.special_field_subdeck_help, buildSuffix(metadata.subdeck)) + SpecialFields.Flag -> { + val code = metadata.flag ?: "N" + context.getString( + R.string.special_field_card_flag_help, + if (code == "N") "flag$code" else "flag$code", + "$code", + Flag.entries.minOf { it.code }, + Flag.entries.maxOf { it.code }, + ) + } + SpecialFields.Tags -> { + val tags = if (metadata.tags.isNullOrBlank()) null else metadata.tags + context.getString(R.string.special_field_tags_help, buildSuffix(tags)) + } + SpecialFields.CardId -> + context.getString(R.string.special_field_card_id_help, buildSuffix(metadata.cardId?.toString())) - // Measure RecyclerView height - recyclerView.measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - ) + SpecialFields.CardTemplate -> + context.getString( + R.string.special_field_card_help, + buildSuffix(metadata.cardTemplateName), + ) - // Update ViewPager height - layoutParams.height = recyclerView.measuredHeight - requestLayout() - } + SpecialFields.NoteType -> + context.getString( + R.string.special_field_type_help, + buildSuffix(metadata.noteTypeName), + ) + // this shouldn't happen + else -> "" + }.parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY) } context(dialog: InsertFieldDialog) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index ab9bab681fa5..cb4cf176f6f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -20,7 +20,12 @@ import android.os.Parcelable import androidx.annotation.CheckResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.common.utils.ellipsize +import com.ichi2.anki.libanki.CardId +import com.ichi2.anki.libanki.Decks +import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.model.FieldName import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.asVar @@ -47,7 +52,12 @@ class InsertFieldDialogViewModel( /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) - private val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) + /** + * State of the selected card when the screen was opened + * + * Used for providing [special fields][SpecialFields] with the output they'd produce. + */ + val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) val selectedFieldFlow = MutableStateFlow(null) @@ -127,4 +137,63 @@ class InsertFieldDialogViewModel( @Parcelize data class InsertFieldMetadata( val side: SingleCardSide, -) : Parcelable + val cardTemplateName: String, + val noteTypeName: String, + val tags: String?, + val flag: Int?, + val cardId: CardId?, + val deck: String?, +) : Parcelable { + val subdeck: String? + get() = deck?.let { Decks.basename(it) } + + companion object { + @CheckResult + suspend fun query( + side: SingleCardSide, + cardTemplateName: String, + noteTypeName: String, + noteId: NoteId?, + ord: Int?, + ): InsertFieldMetadata { + val note = + try { + noteId?.let { nid -> withCol { getNote(nid) } } + } catch (e: Exception) { + Timber.w(e, "failed to get note") + null + } + + // BUG: This is the saved tags of the note, not the currently edited tags + val tags = + note + ?.tags + ?.joinToString(separator = " ") + // truncate, so we don't pass unbounded text into the arguments + ?.ellipsize(75) + + val card = + try { + if (ord == null || note == null) { + null + } else { + // ord can be invalid if the user has in-memory template additions + withCol { note.cards(this).getOrNull(ord) } + } + } catch (e: Exception) { + Timber.w(e, "failed to get card") + null + } + + return InsertFieldMetadata( + side = side, + cardTemplateName = cardTemplateName, + noteTypeName = noteTypeName, + tags = tags, + cardId = card?.id, + flag = card?.userFlag(), + deck = card?.currentDeckId()?.let { did -> withCol { decks.get(did)?.name } }, + ) + } + } +} diff --git a/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml b/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml new file mode 100644 index 000000000000..6789a0ccfc9f --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index 0d84bc7bb489..436ff798846f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -290,4 +290,13 @@ also changes the interval of the card" Fields Special + %1$s’]]> + The front template content. Audio is not automatically played + The full deck of the card, including parent decks%s + The current deck of the card, excluding parent decks%s + Outputs ‘%1$s’, where %2$s is the flag code (%3$d\–%4$d\) + The tags of the note%s + The ID of the card%s + The name of the card template%s + The name of the note type%s diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt new file mode 100644 index 000000000000..e1f4def377af --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.dialogs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.model.SpecialField +import com.ichi2.anki.model.SpecialFields +import com.ichi2.testutils.EmptyApplication +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.not +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.emptyString +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Tests for [InsertFieldDialog] */ +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +class InsertFieldDialogTest : RobolectricTest() { + val metadata = + InsertFieldMetadata( + side = SingleCardSide.FRONT, + cardTemplateName = "A", + noteTypeName = "B", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, + ) + + @Test + fun `all special fields have a descriptions`() { + val allSpecialFields = SpecialFields.ALL + + for (field in allSpecialFields) { + assertThat(field.buildDescription(), not(emptyString())) + } + } + + @Test + fun `{{Type}} description uses note type name`() { + val metadata = metadata.copy(noteTypeName = "A") + + assertThat( + SpecialFields.NoteType.buildDescription(metadata = metadata), + equalTo("The name of the note type: ‘A’"), + ) + } + + @Test + fun `{{Card}} description uses card template name`() { + val metadata = metadata.copy(cardTemplateName = "B") + + assertThat( + SpecialFields.CardTemplate.buildDescription(metadata = metadata), + equalTo( + "The name of the card template: ‘B’", + ), + ) + } + + @Test + fun `{{CardFlag}} description with missing flag`() { + assertThat( + SpecialFields.Flag.buildDescription( + metadata.copy(flag = null), + ), + equalTo("Outputs ‘flagN’, where N is the flag code (0–7)"), + ) + } + + @Test + fun `{{CardFlag}} description with flag`() { + assertThat( + SpecialFields.Flag.buildDescription( + metadata.copy(flag = 3), + ), + equalTo("Outputs ‘flag3’, where 3 is the flag code (0–7)"), + ) + } + + @Test + fun `{{Tags}} description uses tags if set`() { + val metadata = metadata.copy(tags = "one two") + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo("The tags of the note: ‘one two’"), + ) + } + + @Test + fun `{{Tags}} description if tags is blank`() { + val metadata = metadata.copy(tags = " ") + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo("The tags of the note"), + ) + } + + @Test + fun `{{Tags}} description if tags is null`() { + val metadata = metadata.copy(tags = null) + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo( + """ + The tags of the note + """.trimIndent(), + ), + ) + } + + @Test + fun `{{CardID}} description if ID null`() { + val metadata = metadata.copy(cardId = null) + assertThat( + SpecialFields.CardId.buildDescription(metadata), + equalTo("The ID of the card"), + ) + } + + @Test + fun `{{CardID}} description if ID is set`() { + val metadata = metadata.copy(cardId = 1767778189) + assertThat( + SpecialFields.CardId.buildDescription(metadata), + equalTo("The ID of the card: ‘1767778189’"), + ) + } + + @Test + fun `{{Deck}} description if not set`() { + val metadata = metadata.copy(deck = null) + assertThat( + SpecialFields.Deck.buildDescription(metadata), + equalTo("The full deck of the card, including parent decks"), + ) + } + + @Test + fun `{{Deck}} description if set`() { + val metadata = metadata.copy(deck = "aa::bb") + assertThat( + SpecialFields.Deck.buildDescription(metadata), + equalTo("The full deck of the card, including parent decks: ‘aa::bb’"), + ) + } + + @Test + fun `{{Subdeck}} description if not set`() { + val metadata = metadata.copy(deck = null) + assertThat( + SpecialFields.Subdeck.buildDescription(metadata), + equalTo("The current deck of the card, excluding parent decks"), + ) + } + + @Test + fun `{{Subdeck}} description if set`() { + val metadata = metadata.copy(deck = "aa::bb") + assertThat( + SpecialFields.Subdeck.buildDescription(metadata), + equalTo("The current deck of the card, excluding parent decks: ‘bb’"), + ) + } +} + +context(testContext: InsertFieldDialogTest) +fun SpecialField.buildDescription(metadata: InsertFieldMetadata = testContext.metadata) = + buildDescription(testContext.targetContext, metadata).toString() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt index 4bcb2576828e..7ec8a7e77f1d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -122,6 +122,12 @@ class InsertFieldDialogViewModelTest { this[InsertFieldDialogViewModel.KEY_INSERT_FIELD_METADATA] = InsertFieldMetadata( side = side, + cardTemplateName = "Card Template", + noteTypeName = "Note Type", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, ) } withViewModel(savedStateHandle, block) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 634737b91176..07cadd5b7aac 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -60,4 +60,5 @@ dependencies { testImplementation(libs.junit.platform.launcher) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.espresso.core) + testImplementation(kotlin("test")) } diff --git a/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt b/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt index e3eccabe9b6b..e7d4d64fa5f0 100644 --- a/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt +++ b/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt @@ -35,8 +35,8 @@ package com.ichi2.anki.common.utils import com.ichi2.anki.common.annotations.DuplicatedCode -import com.ichi2.anki.common.annotations.NeedsTest import org.jetbrains.annotations.Contract +import java.text.BreakIterator import java.util.Locale import kotlin.math.min @@ -93,3 +93,43 @@ fun String.htmlEncode(): String { } return sb.toString() } + +/** + * Truncates the string to the given maximum length and appends an ellipsis (`…`) + * if the text exceeds that length. + * + * Prefer [android.text.TextUtils.ellipsize] when you have a reference to a TextView + * + * @param ellipsizeAfter when to ellipsize the text. + * `ellipsizeAfter = 2` returns converts `"foo"` to `"fo…"` + * + * @throws IllegalStateException if [`ellipsizeAfter`][ellipsizeAfter]` <= 0` + */ +fun String.ellipsize( + ellipsizeAfter: Int, + locale: Locale = Locale.ROOT, +): String { + require(ellipsizeAfter > 0) { "invalid length: $ellipsizeAfter" } + if (this.length <= ellipsizeAfter) return this + val lastIndex = this.indexOfLastGraphemeCluster(maxChars = ellipsizeAfter, locale) + return this.take(lastIndex) + "…" +} + +private fun String.indexOfLastGraphemeCluster( + maxChars: Int, + locale: Locale, +): Int { + val iterator = BreakIterator.getCharacterInstance(locale) + iterator.setText(this) + + var end = iterator.first() + var lastSafe = end + + while (end != BreakIterator.DONE) { + if (end > maxChars) return lastSafe + lastSafe = end + end = iterator.next() + } + + return lastSafe +} diff --git a/common/src/test/java/com/ichi2/anki/common/utils/StringUtilsTest.kt b/common/src/test/java/com/ichi2/anki/common/utils/StringUtilsTest.kt index 7183b2d7b837..ca00233b1f26 100644 --- a/common/src/test/java/com/ichi2/anki/common/utils/StringUtilsTest.kt +++ b/common/src/test/java/com/ichi2/anki/common/utils/StringUtilsTest.kt @@ -24,6 +24,7 @@ import org.junit.Test import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import kotlin.test.assertFailsWith /** Test of [toTitleCase] */ class StringUtilsTest { @@ -201,4 +202,44 @@ class StringUtilsTest { assertThat(result, equalTo("&<>")) assertFalse(result.contains("&lt;")) } + + @Test + fun ellipsize_input_greater_than_threshold() { + val expected = "Hello1" + assertThat(expected.ellipsize(ellipsizeAfter = 5), equalTo("Hello…")) + } + + @Test + fun ellipsize_input_equal_to_threshold() { + val expected = "Hello" + assertThat(expected.ellipsize(ellipsizeAfter = 5), equalTo("Hello")) + } + + @Test + fun ellipsize_input_less_than_threshold() { + val expected = "Hi" + assertThat(expected.ellipsize(ellipsizeAfter = 5), equalTo("Hi")) + } + + @Test + fun ellipsize_blank_input() { + assertThat("".ellipsize(ellipsizeAfter = 1), equalTo("")) + } + + @Test + fun ellipsize_input_invalid_threshold() { + assertFailsWith { "hello".ellipsize(0) } + assertFailsWith { "hello".ellipsize(-1) } + } + + @Test + fun ellipsize_emoji_is_not_split() { + val input = "Brazil\uD83C\uDDE7\uD83C\uDDF7" + assertThat(input.ellipsize(6), equalTo("Brazil…")) + assertThat(input.ellipsize(7), equalTo("Brazil…")) + assertThat(input.ellipsize(8), equalTo("Brazil…")) + assertThat(input.ellipsize(9), equalTo("Brazil…")) + assertThat(input.ellipsize(10), equalTo("Brazil\uD83C\uDDE7\uD83C\uDDF7")) + assertThat(input + " ".ellipsize(11), equalTo("Brazil\uD83C\uDDE7\uD83C\uDDF7 ")) + } }