From 9d377d30afcf79dbf25a5e1709568519add936d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:36:27 +0000 Subject: [PATCH 1/2] feat: add Compose reimplementation of GapFillTextView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade build system: Gradle 8.4, AGP 8.2.2, Kotlin 1.9.22, Compose BOM 2024.02.01 - Add GapFillParser.kt: sealed Segment hierarchy + parseRawText() - Add GapFillState.kt: GapFillState (mutableStateOf-backed) + rememberGapFillState() - Add GapFillAnnotatedString.kt: AnnotatedString builder, inline deletion-icon content, Spanned→AnnotatedString converter, orderedAnswers helper - Add GapFillPopup.kt: DropdownMenu-based option picker (styled to match popupwnd_bkg.xml) - Add GapFillEditorDialog.kt: Dialog-based free-form editor with animated colour, no-scrim, keyboard handling - Add GapFillText.kt: GapFillConfig data class + GapFillText composable with idle detection, tap handling, and observer callbacks Agent-Logs-Url: https://github.com/rayworks/RichTextView/sessions/db30412a-5e23-4911-a342-83615401ef14 Co-authored-by: rayworks <1329281+rayworks@users.noreply.github.com> --- app/build.gradle | 9 + app/src/main/AndroidManifest.xml | 3 +- build.gradle | 21 +- gradle/wrapper/gradle-wrapper.properties | 2 +- richtextview/build.gradle | 97 ++----- richtextview/src/main/AndroidManifest.xml | 3 +- .../library/compose/GapFillAnnotatedString.kt | 218 +++++++++++++++ .../library/compose/GapFillEditorDialog.kt | 164 ++++++++++++ .../rayworks/library/compose/GapFillParser.kt | 114 ++++++++ .../rayworks/library/compose/GapFillPopup.kt | 98 +++++++ .../rayworks/library/compose/GapFillState.kt | 78 ++++++ .../rayworks/library/compose/GapFillText.kt | 250 ++++++++++++++++++ 12 files changed, 968 insertions(+), 89 deletions(-) create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillAnnotatedString.kt create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillEditorDialog.kt create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillParser.kt create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillPopup.kt create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt create mode 100644 richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt diff --git a/app/build.gradle b/app/build.gradle index b49a51a..290e32b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,9 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { compileSdkVersion rootProject.ext.compileSdkVersion + namespace 'com.rayworks.richtextview' defaultConfig { applicationId "com.rayworks.richtextview" @@ -16,6 +18,13 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } } dependencies { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94fac1b..86b72cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + + , + config: GapFillConfig, + defaultTextColor: Color, + answerChecker: AnswerCorrectionChecker?, +): AnnotatedString { + // Unanswered blanks get an underline only in error-correction + review mode. + val blankDecoration = + if (parsed.errorCorrectionEnabled && config.reviewMode) + TextDecoration.Underline + else + TextDecoration.None + + return buildAnnotatedString { + for (segment in parsed.segments) { + when (segment) { + is Segment.TextSegment -> { + // TextSegment.html already holds the output of Utils.getHtmlTextWithMarkups. + val spanned = HtmlCompat.fromHtml( + segment.html, + HtmlCompat.FROM_HTML_MODE_LEGACY, + ) + append(spannedToAnnotatedString(spanned)) + } + + is Segment.BlankSegment -> { + val answer = filledAnswers[segment.index] + when { + answer == DELETION_SYMBOL -> { + // Use inline content so the deletion icon image is rendered. + val key = "$DELETION_KEY_PREFIX${segment.index}" + pushStringAnnotation(BLANK_ANNOTATION_TAG, segment.index.toString()) + appendInlineContent(key, "[del]") + pop() + } + + answer != null -> { + val color = if ( + config.inputStyle == InputStyle.POPUP_WINDOW && + answerChecker != null + ) { + if (answerChecker.isAnswerCorrect(segment.index, answer)) + config.correctColor + else + config.wrongColor + } else { + Color.Black + } + pushStringAnnotation(BLANK_ANNOTATION_TAG, segment.index.toString()) + pushStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) + append(answer) + pop() // SpanStyle + pop() // annotation + } + + else -> { + // Unanswered: show default placeholder with optional underline. + pushStringAnnotation(BLANK_ANNOTATION_TAG, segment.index.toString()) + pushStyle( + SpanStyle( + color = defaultTextColor, + textDecoration = blankDecoration, + ), + ) + append(segment.defaultPlaceholder) + pop() // SpanStyle + pop() // annotation + } + } + // Mirror original: append a space after each blank span. + append(" ") + } + } + } + } +} + +/** + * Builds the [InlineTextContent] map for blanks whose current answer is the deletion symbol. + * + * Must be called from a Composable context because it calls [painterResource]. + * Returns an empty map when no blank currently displays the deletion icon. + */ +@Composable +fun buildInlineContent( + parsed: GapFillParsed, + filledAnswers: Map, +): Map { + val deletionBlanks = parsed.segments + .filterIsInstance() + .filter { filledAnswers[it.index] == DELETION_SYMBOL } + + if (deletionBlanks.isEmpty()) return emptyMap() + + val painter = painterResource(id = R.drawable.settings_delete) + return deletionBlanks.associate { segment -> + "$DELETION_KEY_PREFIX${segment.index}" to InlineTextContent( + placeholder = Placeholder( + width = 20.sp, + height = 20.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + Image(painter = painter, contentDescription = "Delete") + } + } +} + +/** + * Returns an ordered list of answers for indices 0 until [totalBlanks]. + * + * The deletion symbol is translated to an empty string, matching the original + * `getAllAnswers()` behaviour. + */ +fun orderedAnswers(filledAnswers: Map, totalBlanks: Int): List = + (0 until totalBlanks).map { i -> + val answer = filledAnswers[i] + if (answer == DELETION_SYMBOL) "" else answer ?: "" + } + +// ── Spanned → AnnotatedString ───────────────────────────────────────────────── + +/** + * Converts a [Spanned] (as returned by [HtmlCompat.fromHtml]) to an [AnnotatedString], + * mapping [StyleSpan] and [ForegroundColorSpan] to their Compose equivalents. + */ +private fun spannedToAnnotatedString(spanned: Spanned): AnnotatedString = + buildAnnotatedString { + append(spanned.toString()) + for (span in spanned.getSpans(0, spanned.length, Any::class.java)) { + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + + Typeface.ITALIC -> + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + + Typeface.BOLD_ITALIC -> + addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + ), + start, + end, + ) + } + + is ForegroundColorSpan -> + addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + } + } diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillEditorDialog.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillEditorDialog.kt new file mode 100644 index 0000000..03f6529 --- /dev/null +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillEditorDialog.kt @@ -0,0 +1,164 @@ +package com.rayworks.library.compose + +import android.view.WindowManager +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.rayworks.library.util.Utils + +/** + * Background colour in the normal state — matches `rectangle_gapfill_form_bkg.xml` (`#0078FF`). + */ +private val EditorNormalColor = Color(0xFF0078FF) + +/** + * Background colour when the word limit is exceeded — matches + * `rectangle_gapfill_form_bkg_red.xml` (`#FF6000`). + */ +private val EditorWarningColor = Color(0xFFFF6000) + +/** + * Free-form text editor dialog used in [com.rayworks.library.text.InputStyle.EDITOR_TEXT] mode. + * + * Behaviour mirrors the original `showEditorView()` / `AlertDialog`: + * - The background transitions from blue to orange-red when [wordCountMaxLimit] is exceeded. + * - The confirm button is hidden and a [warningMsg] is shown while over the limit. + * - The soft keyboard is hidden when the dialog is dismissed. + * - The window dim scrim is removed (mirrors `clearFlags(FLAG_DIM_BEHIND)`). + * + * @param hint optional placeholder hint shown inside the text field + * @param warningMsg message displayed when the word count limit is exceeded + * @param confirmText label of the confirm button + * @param wordCountMaxLimit maximum allowed word count (inclusive) + * @param onConfirm invoked with the trimmed answer text when the user confirms; + * only called when the text field is non-blank + * @param onDismiss invoked when the dialog is dismissed (back-press, outside tap, or + * after [onConfirm]) + */ +@Composable +fun GapFillEditorDialog( + hint: String?, + warningMsg: String?, + confirmText: String, + wordCountMaxLimit: Int, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf("") } + val wordCount = remember(text) { Utils.countWordsFromInputText(text) } + val isOverLimit = wordCount > wordCountMaxLimit + + val backgroundColor by animateColorAsState( + targetValue = if (isOverLimit) EditorWarningColor else EditorNormalColor, + label = "editorBackground", + ) + + val keyboardController = LocalSoftwareKeyboardController.current + + Dialog( + onDismissRequest = { + keyboardController?.hide() + onDismiss() + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + // Remove the dim scrim behind the dialog — mirrors FLAG_DIM_BEHIND removal. + val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window + LaunchedEffect(Unit) { + dialogWindow?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + + // Ensure the keyboard is hidden when this composable leaves composition. + DisposableEffect(Unit) { + onDispose { keyboardController?.hide() } + } + + Surface( + shape = RoundedCornerShape(3.dp), + color = backgroundColor, + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = hint?.let { hintText -> + { Text(hintText, color = Color.White.copy(alpha = 0.7f)) } + }, + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + cursorColor = Color.White, + focusedBorderColor = Color.White, + unfocusedBorderColor = Color.White.copy(alpha = 0.5f), + ), + maxLines = 4, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isOverLimit) { + Text( + text = warningMsg ?: "", + color = Color.White, + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + val trimmed = text.trim() + if (trimmed.isNotEmpty()) { + onConfirm(trimmed) + } + keyboardController?.hide() + onDismiss() + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = EditorNormalColor, + ), + ) { + Text(confirmText) + } + } + } + } + } + } +} diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillParser.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillParser.kt new file mode 100644 index 0000000..fb5386f --- /dev/null +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillParser.kt @@ -0,0 +1,114 @@ +package com.rayworks.library.compose + +import androidx.core.text.HtmlCompat +import com.rayworks.library.util.Utils + +/** + * A text segment in the gap-fill content. + * + * The sealed hierarchy is used to represent the interleaved mix of plain text + * and interactive blanks that make up a gap-fill string. + */ +sealed class Segment { + /** + * A non-interactive text run. + * + * [html] contains the HTML-formatted string produced by + * [Utils.getHtmlTextWithMarkups], ready to be parsed by [HtmlCompat.fromHtml]. + */ + data class TextSegment(val html: String) : Segment() + + /** + * An interactive blank. + * + * @param index zero-based position of this blank among all blanks + * @param defaultPlaceholder text shown before the user answers; + * equals [BLANK_TAG] when the source `{}` was empty + */ + data class BlankSegment( + val index: Int, + val defaultPlaceholder: String, + ) : Segment() +} + +/** + * The result of parsing a raw gap-fill string. + * + * @param segments ordered list of [Segment]s + * @param errorCorrectionEnabled `true` when every `{…}` block contained non-empty content, + * enabling the error-correction (underline) rendering mode + * @param totalBlanks number of [Segment.BlankSegment]s in [segments] + */ +data class GapFillParsed( + val segments: List, + val errorCorrectionEnabled: Boolean, + val totalBlanks: Int, +) + +/** Placeholder text shown for empty `{}` blanks (matches `BLANK_TAG` in the Java View). */ +internal const val BLANK_TAG = "____" + +/** + * Parses [rawText] into a [GapFillParsed] structure. + * + * Processing steps: + * 1. Normalise: prepend/append a space when the string starts or ends with `{}`. + * 2. Apply markdown → HTML via [Utils.getHtmlTextWithMarkups] (same first step as the + * original [com.rayworks.library.GapFillTextView]). + * 3. Split the HTML string on `{…}` patterns to produce alternating [Segment.TextSegment] + * and [Segment.BlankSegment] entries. + * + * @throws IllegalArgumentException if [rawText] is empty or contains no blank markers. + */ +fun parseRawText(rawText: String): GapFillParsed { + require(rawText.isNotEmpty()) { "rawText cannot be empty" } + + // Normalise: ensure text never starts or ends directly with a blank block. + var text = rawText + if (text.endsWith("}")) text += " " + if (text.startsWith("{")) text = " $text" + + // Apply markdown → HTML so TextSegment stores ready-to-render HTML strings. + val htmlText = Utils.getHtmlTextWithMarkups(text) + + val blankRegex = Regex("\\{(.*?)\\}") + val segments = mutableListOf() + var lastEnd = 0 + var blankIndex = 0 + var allBlanksHaveContent = true + + for (match in blankRegex.findAll(htmlText)) { + // Capture the text that precedes this blank. + if (match.range.first > lastEnd) { + segments += Segment.TextSegment(htmlText.substring(lastEnd, match.range.first)) + } + + val inner = match.groupValues[1] + val defaultPlaceholder: String + if (inner.isEmpty()) { + defaultPlaceholder = BLANK_TAG + allBlanksHaveContent = false + } else { + // Strip any HTML entities from the placeholder for plain-text display. + defaultPlaceholder = + HtmlCompat.fromHtml(inner, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() + } + + segments += Segment.BlankSegment(blankIndex, defaultPlaceholder) + blankIndex++ + lastEnd = match.range.last + 1 + } + + // Capture any trailing text after the last blank. + if (lastEnd < htmlText.length) { + segments += Segment.TextSegment(htmlText.substring(lastEnd)) + } + + require(blankIndex > 0) { "Target String has illegal format: no blank markers '{}' found." } + + return GapFillParsed( + segments = segments, + errorCorrectionEnabled = allBlanksHaveContent, + totalBlanks = blankIndex, + ) +} diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillPopup.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillPopup.kt new file mode 100644 index 0000000..cd36388 --- /dev/null +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillPopup.kt @@ -0,0 +1,98 @@ +package com.rayworks.library.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.rayworks.library.R + +/** Background colour of the option-picker popup — matches `popupwnd_bkg.xml`. */ +private val PopupBackground = Color(0xFF177CFB) + +/** Text/icon tint colour used for all items in the popup. */ +private val PopupContentColor = Color.White + +/** Label shown alongside the deletion icon in the popup list. */ +private const val DELETION_LABEL = "Delete" + +/** Maximum number of items visible before the popup list becomes scrollable. */ +private const val MAX_VISIBLE_ITEMS = 4 + +/** Approximate height of a single popup item. */ +private val ItemHeight = 48.dp + +/** + * Popup option picker used in [com.rayworks.library.text.InputStyle.POPUP_WINDOW] mode. + * + * Displays a styled [DropdownMenu] that lists the answer options for the active blank. + * Up to [MAX_VISIBLE_ITEMS] items are shown before the list becomes scrollable, mirroring + * the `getFirstItemHeight() * min(4, count)` height calculation in the original Java code. + * + * An empty string in [options] represents the deletion option: a trash-icon row is rendered + * instead of a plain text label (mirrors [com.rayworks.library.adapter.OptionAdapter]). + * + * @param expanded whether the popup is currently visible + * @param options answer options for the active blank; + * an empty string indicates the deletion option + * @param onOptionChosen called with the canonical answer string when the user selects an item; + * an empty option is translated to [DELETION_SYMBOL] + * @param onDismiss called when the popup is dismissed without a selection + */ +@Composable +fun GapFillOptionPopup( + expanded: Boolean, + options: List, + onOptionChosen: (String) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + offset = DpOffset(0.dp, 0.dp), + modifier = Modifier + .background(PopupBackground, RoundedCornerShape(8.dp)) + .widthIn(min = 120.dp) + .heightIn(max = ItemHeight * MAX_VISIBLE_ITEMS), + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + if (option.isEmpty()) { + // Deletion option: icon + label (mirrors OptionAdapter view_text_center). + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.settings_delete), + contentDescription = DELETION_LABEL, + tint = PopupContentColor, + ) + Text( + text = DELETION_LABEL, + color = PopupContentColor, + modifier = Modifier.padding(start = 8.dp), + ) + } + } else { + Text(text = option, color = PopupContentColor) + } + }, + onClick = { + // Empty option string → canonical deletion symbol (matches original). + onOptionChosen(if (option.isEmpty()) DELETION_SYMBOL else option) + }, + ) + } + } +} diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt new file mode 100644 index 0000000..58ac6c8 --- /dev/null +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt @@ -0,0 +1,78 @@ +package com.rayworks.library.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Mutable runtime state for [GapFillText]. + * + * This class is a Compose state holder: its properties are backed by [mutableStateOf] so any + * composable that reads them is automatically recomposed when they change. + * + * Hoist an instance outside the composable tree to observe and manipulate the answers. + * Create and remember one with [rememberGapFillState]. + */ +class GapFillState { + + /** Map from blank index (0-based) to the answer string chosen or typed by the user. */ + var filledAnswers: Map by mutableStateOf(emptyMap()) + + /** + * Index of the blank for which an input overlay (popup or editor dialog) is currently + * open, or `null` when no overlay is visible. + */ + var activeBlankIndex: Int? by mutableStateOf(null) + + /** Clears all filled answers and closes any open input overlay. */ + fun reset() { + filledAnswers = emptyMap() + activeBlankIndex = null + } + + /** + * Returns the first blank index that has not yet been answered, or `null` if all blanks + * have been filled. + * + * Useful for scrolling the UI to the next unfilled blank (mirrors `getHintLineIndex()`). + */ + fun firstUnfilledBlankIndex(segments: List): Int? = + segments + .filterIsInstance() + .firstOrNull { !filledAnswers.containsKey(it.index) } + ?.index + + /** + * Returns the filled text with every answered blank substituted back into [rawText]. + * + * @throws IllegalStateException if not all blanks have been answered. + */ + fun formattedFullText(rawText: String): String { + // Apply the same normalisation used during parsing. + var normalised = rawText + if (normalised.endsWith("}")) normalised += " " + if (normalised.startsWith("{")) normalised = " $normalised" + + val parts = normalised.split("{}") + val blankCount = parts.size - 1 + check(filledAnswers.size == blankCount) { + "Not all blanks have been filled: expected $blankCount, got ${filledAnswers.size}" + } + + return buildString { + append(parts[0]) + for (i in 0 until blankCount) { + append('{') + append(filledAnswers[i]) + append('}') + append(parts[i + 1]) + } + } + } +} + +/** Creates and [remember]s a [GapFillState] instance that survives recompositions. */ +@Composable +fun rememberGapFillState(): GapFillState = remember { GapFillState() } diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt new file mode 100644 index 0000000..cbdad27 --- /dev/null +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt @@ -0,0 +1,250 @@ +package com.rayworks.library.compose + +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContentColor +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.rayworks.library.listener.AnswerCorrectionChecker +import com.rayworks.library.listener.AnswerResultObserver +import com.rayworks.library.text.InputStyle +import kotlinx.coroutines.delay + +/** + * Immutable configuration for [GapFillText]. + * + * @param rawText gap-fill source string; use `{}` for a blank with no default + * placeholder, or `{placeholder}` to show hint text until answered + * @param optionItems per-blank option lists, required when + * [inputStyle] is [InputStyle.POPUP_WINDOW]; + * an empty string inside a list represents the deletion option + * @param inputStyle [InputStyle.POPUP_WINDOW] (multiple-choice) or + * [InputStyle.EDITOR_TEXT] (free-form typing) + * @param correctColor foreground colour applied to correctly answered blanks + * @param wrongColor foreground colour applied to incorrectly answered blanks + * @param reviewMode when `true` blanks are rendered read-only (not tappable) + * @param wordCountMaxLimit maximum word count for [InputStyle.EDITOR_TEXT] mode; + * the editor background turns orange when exceeded + * @param editorHint hint text shown in the editor text field + * @param editorWarningMsg message shown when the word count limit is exceeded + * @param editorConfirmText label of the editor confirm button (default `"OK"`) + * @param idleTimeoutMs milliseconds of user inactivity before + * [AnswerResultObserver.onIdleState] is fired + * (only active when [GapFillParsed.errorCorrectionEnabled] is true) + */ +data class GapFillConfig( + val rawText: String, + val optionItems: List> = emptyList(), + val inputStyle: InputStyle = InputStyle.POPUP_WINDOW, + val correctColor: Color = Color.Green, + val wrongColor: Color = Color.Red, + val reviewMode: Boolean = false, + val wordCountMaxLimit: Int = 128, + val editorHint: String? = null, + val editorWarningMsg: String? = null, + val editorConfirmText: String = "OK", + val idleTimeoutMs: Long = 20_000L, +) { + init { + require(rawText.isNotEmpty()) { "rawText cannot be empty" } + if (inputStyle == InputStyle.POPUP_WINDOW) { + require(optionItems.isNotEmpty()) { + "optionItems cannot be empty for POPUP_WINDOW style" + } + } + } +} + +/** + * Compose reimplementation of `GapFillTextView`. + * + * Renders a gap-fill text where each blank is an interactive tap target. Depending on + * [GapFillConfig.inputStyle] a popup option picker ([GapFillOptionPopup]) or a free-form editor + * dialog ([GapFillEditorDialog]) appears when the user taps a blank. + * + * The outer Box carries the `writing_text_frame_bkg` border (white fill, light-grey 1dp stroke, + * 8dp corner radius). + * + * **State hoisting**: pass your own [GapFillState] to observe or drive the component from + * outside; otherwise [rememberGapFillState] creates a locally remembered instance. + * + * **Callbacks**: [answerChecker] and [resultObserver] are optional. If provided they are called + * after every answer selection — matching the original observer pattern. + * + * @param config immutable display and behaviour configuration + * @param state mutable runtime state; use [rememberGapFillState] when not hoisting + * @param answerChecker optional checker used to colour answered blanks and determine correctness + * @param resultObserver optional observer for answer-selection lifecycle callbacks + * @param modifier applied to the outer [Box] + * @param textStyle [TextStyle] passed to the underlying [BasicText] + */ +@Composable +fun GapFillText( + config: GapFillConfig, + state: GapFillState = rememberGapFillState(), + answerChecker: AnswerCorrectionChecker? = null, + resultObserver: AnswerResultObserver? = null, + modifier: Modifier = Modifier, + textStyle: TextStyle = TextStyle.Default, +) { + // Parse the raw text once per rawText change; result is structurally stable. + val parsed = remember(config.rawText) { parseRawText(config.rawText) } + + // Keep the latest observer / checker references available inside lambdas without + // making them LaunchedEffect / pointerInput restart keys. + val currentChecker by rememberUpdatedState(answerChecker) + val currentObserver by rememberUpdatedState(resultObserver) + + // interactionKey increments on every blank tap; drives the idle-detection timer below. + var interactionKey by remember { mutableIntStateOf(0) } + + // ── Idle-state detection ────────────────────────────────────────────────── + // LaunchedEffect restarts whenever interactionKey changes, which cancels and reschedules + // the delay — equivalent to handler.removeCallbacks + postDelayed in the original. + LaunchedEffect(interactionKey) { + if (parsed.errorCorrectionEnabled && interactionKey > 0) { + delay(config.idleTimeoutMs) + if (state.filledAnswers.size < parsed.totalBlanks) { + currentObserver?.onIdleState() + } + } + } + + // ── AnnotatedString ─────────────────────────────────────────────────────── + val defaultTextColor = LocalContentColor.current + val annotatedText = remember( + parsed, + state.filledAnswers, + config, + defaultTextColor, + answerChecker, + ) { + buildGapFillAnnotatedString( + parsed = parsed, + filledAnswers = state.filledAnswers, + config = config, + defaultTextColor = defaultTextColor, + answerChecker = answerChecker, + ) + } + + // InlineTextContent for deletion-icon blanks — rebuilt when filledAnswers changes. + val inlineContent = buildInlineContent(parsed, state.filledAnswers) + + // Layout result used to map tap-offset → character-offset. + val layoutResult = remember { mutableStateOf(null) } + + // Keep the latest annotatedText visible inside the gesture lambda without restarting + // the gesture detector on every recomposition. + val currentAnnotatedText by rememberUpdatedState(annotatedText) + + // ── Answer population ───────────────────────────────────────────────────── + // Declared as a local function so it closes over the stable `parsed` value. + fun populateAnswer(blankIndex: Int, answer: String) { + val wasFirstSelection = !state.filledAnswers.containsKey(blankIndex) + state.filledAnswers = state.filledAnswers + (blankIndex to answer) + + val answers = orderedAnswers(state.filledAnswers, parsed.totalBlanks) + + if (config.inputStyle == InputStyle.POPUP_WINDOW && currentChecker != null) { + val correct = currentChecker!!.isAnswerCorrect(blankIndex, answer) + if (correct && wasFirstSelection) { + currentObserver?.onAnswerCorrectForFirstTime(blankIndex) + } + } + + val correctNum = currentChecker?.retrieveCorrectAnswers(answers) ?: 0 + currentObserver?.onAnswerSelected( + correctNum, + state.filledAnswers.size, + parsed.totalBlanks, + ) + + if (state.filledAnswers.size == parsed.totalBlanks && currentChecker != null) { + val allCorrect = currentChecker!!.allAnswerCorrect(answers) + currentObserver?.onHandleAllAnswerCorrect(allCorrect) + } + } + + // ── Layout ──────────────────────────────────────────────────────────────── + // Outer Box provides the writing_text_frame_bkg border and acts as the anchor for the popup. + Box( + modifier = modifier.border(1.dp, Color(0xFFE7E7E7), RoundedCornerShape(8.dp)), + ) { + BasicText( + text = annotatedText, + style = textStyle, + onTextLayout = { layoutResult.value = it }, + inlineContent = inlineContent, + modifier = Modifier.pointerInput(config.reviewMode) { + if (!config.reviewMode) { + detectTapGestures { tapOffset -> + layoutResult.value?.let { layout -> + val charOffset = layout.getOffsetForPosition(tapOffset) + currentAnnotatedText + .getStringAnnotations( + BLANK_ANNOTATION_TAG, + charOffset, + charOffset, + ) + .firstOrNull() + ?.let { annotation -> + state.activeBlankIndex = annotation.item.toInt() + interactionKey++ + } + } + } + } + }, + ) + + // ── Input overlay ───────────────────────────────────────────────────── + val activeIndex = state.activeBlankIndex + if (activeIndex != null) { + when (config.inputStyle) { + InputStyle.POPUP_WINDOW -> { + val options = if (activeIndex < config.optionItems.size) + config.optionItems[activeIndex] + else + emptyList() + + GapFillOptionPopup( + expanded = true, + options = options, + onOptionChosen = { answer -> + populateAnswer(activeIndex, answer) + state.activeBlankIndex = null + }, + onDismiss = { state.activeBlankIndex = null }, + ) + } + + InputStyle.EDITOR_TEXT -> { + GapFillEditorDialog( + hint = config.editorHint, + warningMsg = config.editorWarningMsg, + confirmText = config.editorConfirmText, + wordCountMaxLimit = config.wordCountMaxLimit, + onConfirm = { answer -> populateAnswer(activeIndex, answer) }, + onDismiss = { state.activeBlankIndex = null }, + ) + } + } + } + } +} From 4a15ce470aa818a64d3b036a107272a8c95830e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:38:45 +0000 Subject: [PATCH 2/2] fix: address code review feedback - formattedFullText: use regex to match both {} and {placeholder} blanks - populateAnswer: use local val to avoid redundant !! assertions on currentChecker Agent-Logs-Url: https://github.com/rayworks/RichTextView/sessions/db30412a-5e23-4911-a342-83615401ef14 Co-authored-by: rayworks <1329281+rayworks@users.noreply.github.com> --- .../com/rayworks/library/compose/GapFillState.kt | 15 ++++++++++----- .../com/rayworks/library/compose/GapFillText.kt | 6 ++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt index 58ac6c8..07c85e2 100644 --- a/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillState.kt @@ -55,20 +55,25 @@ class GapFillState { if (normalised.endsWith("}")) normalised += " " if (normalised.startsWith("{")) normalised = " $normalised" - val parts = normalised.split("{}") - val blankCount = parts.size - 1 + // Use a regex to match both {} and {placeholder} blank markers so that the + // method works regardless of whether blanks had default placeholders. + val blankRegex = Regex("\\{.*?\\}") + val matches = blankRegex.findAll(normalised).toList() + val blankCount = matches.size check(filledAnswers.size == blankCount) { "Not all blanks have been filled: expected $blankCount, got ${filledAnswers.size}" } return buildString { - append(parts[0]) - for (i in 0 until blankCount) { + var lastEnd = 0 + matches.forEachIndexed { i, match -> + append(normalised.substring(lastEnd, match.range.first)) append('{') append(filledAnswers[i]) append('}') - append(parts[i + 1]) + lastEnd = match.range.last + 1 } + append(normalised.substring(lastEnd)) } } } diff --git a/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt b/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt index cbdad27..69fab56 100644 --- a/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt +++ b/richtextview/src/main/java/com/rayworks/library/compose/GapFillText.kt @@ -162,7 +162,8 @@ fun GapFillText( val answers = orderedAnswers(state.filledAnswers, parsed.totalBlanks) if (config.inputStyle == InputStyle.POPUP_WINDOW && currentChecker != null) { - val correct = currentChecker!!.isAnswerCorrect(blankIndex, answer) + val checker = currentChecker + val correct = checker.isAnswerCorrect(blankIndex, answer) if (correct && wasFirstSelection) { currentObserver?.onAnswerCorrectForFirstTime(blankIndex) } @@ -176,7 +177,8 @@ fun GapFillText( ) if (state.filledAnswers.size == parsed.totalBlanks && currentChecker != null) { - val allCorrect = currentChecker!!.allAnswerCorrect(answers) + val checker = currentChecker + val allCorrect = checker.allAnswerCorrect(answers) currentObserver?.onHandleAllAnswerCorrect(allCorrect) } }