feat(compose): add font color and text highlight to the formatting toolbar #189

Merged
JMR-dev merged 3 commits from feat-74-75-compose-color-highlight into main 2026-07-03 02:26:03 +00:00
3 changed files with 224 additions and 7 deletions
@@ -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<ColorSwatch>,
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<ColorSwatch> = 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<ColorSwatch> = 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 <T : RichStyle> clearStyle(
value: TextFieldValue,
kind: Class<T>,
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)
+14
View File
@@ -107,6 +107,20 @@
<string name="format_link_apply">Link</string>
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
<string name="format_color_none">No color</string>
<string name="format_color">Font color</string>
<string name="format_color_black">Black</string>
<string name="format_color_gray">Gray</string>
<string name="format_color_red">Red</string>
<string name="format_color_orange">Orange</string>
<string name="format_color_yellow">Yellow</string>
<string name="format_color_green">Green</string>
<string name="format_color_blue">Blue</string>
<string name="format_color_purple">Purple</string>
<string name="format_highlight">Highlight</string>
<string name="format_highlight_yellow">Yellow</string>
<string name="format_highlight_green">Green</string>
<string name="format_highlight_cyan">Cyan</string>
<string name="format_highlight_pink">Pink</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -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