From 5120d657935cec51c2767484c1d67ae02691b237 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 20:52:09 -0500 Subject: [PATCH] feat(compose): add font color and text highlight to the formatting toolbar Wire the previously-unused ColorSwatchRow into the compose FormattingToolbar with two new controls: a font-color button applying RichStyle.FontColor and a highlight button applying RichStyle.Highlight over the selection. Each opens a ColorPickerDialog built on the shared ColorSwatchRow (~8 font colors; yellow / green / cyan / pink highlighter markers), with a "no color"/"none" entry that clears the style outright via a new clearStyle op. Buttons reflect the current selection's color and carry accessible onClickLabels; swatches carry their own contentDescriptions. Closes #74 Closes #75 Co-Authored-By: Claude Opus 4.8 --- .../libremail/ui/compose/RichTextEditor.kt | 174 +++++++++++++++++- app/src/main/res/values/strings.xml | 14 ++ .../ui/compose/RichTextEditorTest.kt | 43 +++++ 3 files changed, 224 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt index f97dae1..0a3c6cd 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -56,6 +56,8 @@ import org.libremail.richtext.RichStyle import org.libremail.richtext.RichTextContent import org.libremail.richtext.RichTextEditing import org.libremail.richtext.RichTextHtml +import org.libremail.ui.compose.format.ColorSwatch +import org.libremail.ui.compose.format.ColorSwatchRow /** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */ private const val URL_TAG = "libremail:url" @@ -71,15 +73,17 @@ internal const val STYLE_TAG = "libremail:style" internal const val IMAGE_TAG = "libremail:image" /** - * A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered - * lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its - * [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its - * HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and - * feels exactly like the old editor. + * A rich-text body editor: a formatting toolbar (bold / italic / underline / strikethrough, font + * color and highlight, bulleted + numbered lists, block quote, and link) above a rounded + * [OutlinedTextField]. It converts its [AnnotatedString] to the app's [RichTextContent] model and + * reports both the plaintext form and its HTML — or null HTML when nothing is formatted, so an + * unformatted message stays plaintext-only and feels exactly like the old editor. * * The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all * work as usual; each toolbar button exposes its accessible action label via `onClickLabel` on its * [Modifier.clickable] (not a `contentDescription`), and still carries toggle state for accessibility. + * The font-color and highlight buttons open a [ColorPickerDialog] built on the shared + * [ColorSwatchRow], whose individual swatches carry their own `contentDescription` instead. * * [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default * resolves nothing, leaving the system font (the model still round-trips the CSS value untouched). @@ -119,6 +123,8 @@ fun RichTextBodyField( } var showLinkDialog by remember { mutableStateOf(false) } + var showFontColorPicker by remember { mutableStateOf(false) } + var showHighlightPicker by remember { mutableStateOf(false) } Column(modifier) { FormattingToolbar( @@ -126,6 +132,8 @@ fun RichTextBodyField( onToggleStyle = { style -> emit(applyStyle(value, style, linkColor, resolveFont)) }, onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor, resolveFont)) }, onLink = { showLinkDialog = true }, + onFontColor = { showFontColorPicker = true }, + onHighlight = { showHighlightPicker = true }, ) OutlinedTextField( value = value, @@ -149,6 +157,56 @@ fun RichTextBodyField( }, ) } + + if (showFontColorPicker) { + val current = RichTextEditing.styleAt( + value.annotatedString.toRichContent(), + value.selection.min, + value.selection.max, + RichStyle.FontColor::class.java, + ) + ColorPickerDialog( + title = stringResource(R.string.format_color), + swatches = fontColorSwatches(), + selectedArgb = current?.argb, + onDismiss = { showFontColorPicker = false }, + onSelect = { argb -> + emit( + if (argb != null) { + applyStyle(value, RichStyle.FontColor(argb), linkColor, resolveFont) + } else { + clearStyle(value, RichStyle.FontColor::class.java, linkColor, resolveFont) + }, + ) + showFontColorPicker = false + }, + ) + } + + if (showHighlightPicker) { + val current = RichTextEditing.styleAt( + value.annotatedString.toRichContent(), + value.selection.min, + value.selection.max, + RichStyle.Highlight::class.java, + ) + ColorPickerDialog( + title = stringResource(R.string.format_highlight), + swatches = highlightSwatches(), + selectedArgb = current?.argb, + onDismiss = { showHighlightPicker = false }, + onSelect = { argb -> + emit( + if (argb != null) { + applyStyle(value, RichStyle.Highlight(argb), linkColor, resolveFont) + } else { + clearStyle(value, RichStyle.Highlight::class.java, linkColor, resolveFont) + }, + ) + showHighlightPicker = false + }, + ) + } } @Composable @@ -157,6 +215,8 @@ private fun FormattingToolbar( onToggleStyle: (RichStyle) -> Unit, onToggleBlock: (BlockMarker) -> Unit, onLink: () -> Unit, + onFontColor: () -> Unit, + onHighlight: () -> Unit, ) { val content = value.annotatedString.toRichContent() val start = value.selection.min @@ -197,6 +257,22 @@ private fun FormattingToolbar( strikethrough = true, onClick = { onToggleStyle(RichStyle.Strikethrough) }, ) + val fontColorArgb = RichTextEditing.styleAt(content, start, end, RichStyle.FontColor::class.java)?.argb + FormatButton( + label = "A", + description = stringResource(R.string.format_color), + active = fontColorArgb != null, + tint = fontColorArgb?.let { Color(it) }, + onClick = onFontColor, + ) + val highlightArgb = RichTextEditing.styleAt(content, start, end, RichStyle.Highlight::class.java)?.argb + FormatButton( + label = "H", + description = stringResource(R.string.format_highlight), + active = false, + swatchColor = highlightArgb?.let { Color(it) }, + onClick = onHighlight, + ) FormatButton( label = "•", description = stringResource(R.string.format_bullet_list), @@ -234,10 +310,12 @@ private fun FormatButton( fontStyle: FontStyle? = null, underline: Boolean = false, strikethrough: Boolean = false, + tint: Color? = null, + swatchColor: Color? = null, ) { val colors = MaterialTheme.colorScheme - val background = if (active) colors.secondaryContainer else Color.Transparent - val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant + val background = swatchColor ?: if (active) colors.secondaryContainer else Color.Transparent + val textColor = tint ?: if (active) colors.onSecondaryContainer else colors.onSurfaceVariant Box( modifier = Modifier .clip(MaterialTheme.shapes.small) @@ -291,6 +369,59 @@ private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (Stri ) } +/** + * A color-swatch picker shared by the font-color and highlight toolbar buttons. [title] names which + * one; [swatches] is the fixed palette; [selectedArgb] rings the swatch (if any) uniformly applied + * over the current selection. Tapping any swatch - or the built-in "no color" entry, which + * [ColorSwatchRow] reports as a null argument - calls [onSelect]; the caller applies the change and + * dismisses, mirroring how [LinkDialog]'s `onConfirm` is wired. + */ +@Composable +private fun ColorPickerDialog( + title: String, + swatches: List, + selectedArgb: Int?, + onDismiss: () -> Unit, + onSelect: (Int?) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + ColorSwatchRow( + swatches = swatches, + selectedArgb = selectedArgb, + onSelect = onSelect, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + +/** ~8 fixed font colors for the font-color picker; "no color" is [ColorSwatchRow]'s own leading entry. */ +@Composable +private fun fontColorSwatches(): List = listOf( + ColorSwatch(0xFF000000.toInt(), stringResource(R.string.format_color_black)), + ColorSwatch(0xFF616161.toInt(), stringResource(R.string.format_color_gray)), + ColorSwatch(0xFFD32F2F.toInt(), stringResource(R.string.format_color_red)), + ColorSwatch(0xFFEF6C00.toInt(), stringResource(R.string.format_color_orange)), + ColorSwatch(0xFFF9A825.toInt(), stringResource(R.string.format_color_yellow)), + ColorSwatch(0xFF2E7D32.toInt(), stringResource(R.string.format_color_green)), + ColorSwatch(0xFF1565C0.toInt(), stringResource(R.string.format_color_blue)), + ColorSwatch(0xFF6A1B9A.toInt(), stringResource(R.string.format_color_purple)), +) + +/** Typical marker colors for the highlight picker; "none" is [ColorSwatchRow]'s own leading entry. */ +@Composable +private fun highlightSwatches(): List = listOf( + ColorSwatch(0xFFFFF59D.toInt(), stringResource(R.string.format_highlight_yellow)), + ColorSwatch(0xFFA5D6A7.toInt(), stringResource(R.string.format_highlight_green)), + ColorSwatch(0xFF80DEEA.toInt(), stringResource(R.string.format_highlight_cyan)), + ColorSwatch(0xFFF48FB1.toInt(), stringResource(R.string.format_highlight_pink)), +) + // --- editor-op plumbing (TextFieldValue <-> RichTextContent) --- /** Toggles [style] over the selection and rebuilds the field value (one-liner for toolbar wiring). */ @@ -342,6 +473,35 @@ internal fun applyLink( return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection) } +/** + * Clears every span of style kind [kind] over the selection - the color pickers' "no color"/"none" + * entry, which must remove the style outright regardless of its value. [applyStyle] cannot do this: + * it delegates to [RichTextEditing.toggleStyle], which only clears when the selection is already + * uniformly one exact value, and otherwise applies that value instead of removing it. + */ +internal fun clearStyle( + value: TextFieldValue, + kind: Class, + linkColor: Color, + resolveFont: (String) -> FontFamily? = { null }, +): TextFieldValue { + val start = value.selection.min + val end = value.selection.max + if (start >= end) return value + val content = value.annotatedString.toRichContent() + val kept = content.spans.flatMap { span -> + when { + !kind.isInstance(span.style) || span.end <= start || span.start >= end -> listOf(span) + else -> buildList { + if (span.start < start) add(span.copy(end = start)) + if (span.end > end) add(span.copy(start = end)) + } + } + } + val updated = content.copy(spans = kept.sortedBy { it.start }) + return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection) +} + private fun seedContent(body: String, bodyHtml: String?): RichTextContent = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8b8cb7..0373255 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,20 @@ Link Select some text first, then add a link. No color + Font color + Black + Gray + Red + Orange + Yellow + Green + Blue + Purple + Highlight + Yellow + Green + Cyan + Pink Drafts diff --git a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt index 38414a9..5af9e47 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt @@ -164,6 +164,49 @@ class RichTextEditorTest { assertTrue(result.annotatedString.toRichContent().spans.isEmpty()) } + // --- clearStyle --- + + @Test + fun `clearStyle removes a font color span regardless of its value`() { + val value = field("hello", TextRange(0, 5)) + val colored = applyStyle(value, RichStyle.FontColor(0xFFFF0000.toInt()), linkColor) + val cleared = clearStyle(colored, RichStyle.FontColor::class.java, linkColor) + assertTrue(cleared.annotatedString.toRichContent().spans.isEmpty()) + } + + @Test + fun `clearStyle splits a span that only partially overlaps the selection`() { + val value = field("abcdef", TextRange(0, 6)) + val colored = applyStyle(value, RichStyle.Highlight(0xFFFFFF00.toInt()), linkColor) + val narrowed = colored.copy(selection = TextRange(2, 4)) + val cleared = clearStyle(narrowed, RichStyle.Highlight::class.java, linkColor) + assertEquals( + listOf( + RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())), + RichSpan(4, 6, RichStyle.Highlight(0xFFFFFF00.toInt())), + ), + cleared.annotatedString.toRichContent().spans, + ) + } + + @Test + fun `clearStyle only removes spans of the given kind, leaving other styles intact`() { + val value = field("hi", TextRange(0, 2)) + val bolded = applyStyle(value, RichStyle.Bold, linkColor) + val both = applyStyle(bolded, RichStyle.FontColor(0xFF000000.toInt()), linkColor) + val cleared = clearStyle(both, RichStyle.FontColor::class.java, linkColor) + assertEquals(listOf(RichSpan(0, 2, RichStyle.Bold)), cleared.annotatedString.toRichContent().spans) + } + + @Test + fun `clearStyle is a no-op with a collapsed selection`() { + val value = field("hello", TextRange(0, 5)) + val colored = applyStyle(value, RichStyle.FontColor(0xFFFF0000.toInt()), linkColor) + val collapsed = colored.copy(selection = TextRange(2)) + val result = clearStyle(collapsed, RichStyle.FontColor::class.java, linkColor) + assertEquals(colored.annotatedString.toRichContent().spans, result.annotatedString.toRichContent().spans) + } + // --- applyBlock --- @Test