diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 85f29417227b..cb2160320509 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 @@ -155,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 @@ -371,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)) { @@ -467,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 @@ -535,11 +544,23 @@ 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" 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 +667,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) @@ -756,8 +778,44 @@ 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 -> - val dialog = InsertFieldDialog.newInstance(fieldNames, insertFieldRequestKey) + 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.query( + side = side, + noteId = noteId, + ord = ord, + cardTemplateName = templateName, + noteTypeName = tempModel.notetype.name, + ), + requestKey = insertFieldRequestKey, + ) templateEditor.showDialogFragment(dialog) } } @@ -771,7 +829,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( @@ -796,19 +854,17 @@ 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") } } - @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( @@ -864,7 +920,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) { @@ -915,12 +971,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 } @@ -1117,9 +1172,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, @@ -1162,13 +1215,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, @@ -1197,8 +1249,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") @@ -1509,6 +1560,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 4f323784eefd..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,19 +16,46 @@ 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.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.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.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.customListAdapter 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 @@ -37,59 +64,76 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { - private lateinit var fieldList: List - private lateinit var requestKey: String + private lateinit var binding: DialogInsertFieldBinding + private val viewModel by viewModels() + 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) - fieldList = requireArguments().getStringArrayList(KEY_FIELD_ITEMS)!! - 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 - textView.text = fieldList[position] - textView.setOnClickListener { selectFieldAndClose(textView) } + 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) + } + + 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) } + }, + ) - override fun getItemCount(): Int = fieldList.size + // setup flows + launchCatchingTask { + viewModel.selectedFieldFlow.collect { field -> + if (field == null) return@collect + parentFragmentManager.setFragmentResult( + requestKey, + bundleOf(KEY_INSERTED_FIELD to field.renderToTemplateTag()), + ) + dismiss() } - return AlertDialog.Builder(requireContext()).create { - title(R.string.card_template_editor_select_field) - negativeButton(R.string.dialog_cancel) - customListAdapter(adapter) } - } - private fun selectFieldAndClose(textView: TextView) { - parentFragmentManager.setFragmentResult( - requestKey, - bundleOf(KEY_INSERTED_FIELD to textView.text.toString()), - ) - dismiss() + return dialog } 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] @@ -100,14 +144,185 @@ 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, ) } } + + 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 + } + } + + override fun onResume() { + super.onResume() + this.requireView().post { requireView().requestLayout() } + } + } + + 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, + ) = InsertFieldViewHolder( + DialogInsertSpecialFieldRecyclerItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) + + override fun onBindViewHolder( + holder: InsertFieldViewHolder, + position: Int, + ) { + val field = viewModel.specialFields[position] + + 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) +} + +@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)) + + 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())) + + SpecialFields.CardTemplate -> + context.getString( + R.string.special_field_card_help, + buildSuffix(metadata.cardTemplateName), + ) + + 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) +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 new file mode 100644 index 000000000000..cb4cf176f6f8 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -0,0 +1,199 @@ +/* + * 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 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 +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 + +/** + * ViewModel for [InsertFieldDialog] + * + * Handles availability of fields + */ +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) + + /** + * 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) + + /** + * 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 + */ + fun selectNamedField(fieldName: FieldName) { + Timber.i("selected named field") + if (!fieldNames.contains(fieldName)) return + selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) + } + + /** + * 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) + } + + 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) + } + } + + class SpecialField( + val model: SpecialFieldModel, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{${model.name}}}" + } + + /** + * Renders the field for use in the Card Template + * + * Example: `{{type:Front}}` + */ + @CheckResult + 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" + } +} + +@Parcelize +data class InsertFieldMetadata( + val side: SingleCardSide, + 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/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/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/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/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 8348a8795762..436ff798846f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -286,4 +286,17 @@ 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 + %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/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/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 new file mode 100644 index 000000000000..7ec8a7e77f1d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -0,0 +1,142 @@ +/* + * 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.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 +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}}")) + } + + @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, + cardTemplateName = "Card Template", + noteTypeName = "Note Type", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, + ) + } + withViewModel(savedStateHandle, block) + } + + fun withViewModel( + savedStateHandle: SavedStateHandle, + block: InsertFieldDialogViewModel.() -> Unit, + ) { + InsertFieldDialogViewModel(savedStateHandle).run(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 ")) + } }