diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
index 662f1a8..bc014dd 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
@@ -3,6 +3,7 @@ package org.libremail.ui.compose
import android.Manifest
import androidx.activity.ComponentActivity
+import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
@@ -169,6 +170,59 @@ class ComposeScreenTest {
assertEquals("bcc@example.com", sent.bcc)
}
+ @Test
+ fun formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml() {
+ val mailRepository = FakeMailRepository()
+ setContent(mailRepository)
+
+ composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
+ composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Buy milk")
+ // The bullet-list button is deliberately chosen over the inline styles (bold/italic): block
+ // markers apply to the caret's whole line, so the end-of-text caret that performTextInput
+ // leaves is enough - no on-device range selection (which is unreliable in instrumented tests)
+ // is needed to prove that a toolbar tap flows real formatting into the sent message's HTML.
+ // "•" is the bullet button's own (untranslated) glyph label - see FormattingToolbar.
+ composeTestRule.onNodeWithText("•").performClick()
+ composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
+ val sent = mailRepository.sentMessages.single()
+ // Plaintext keeps the readable "• " marker; the HTML part carries the real
/- structure.
+ assertEquals("• Buy milk", sent.body)
+ assertTrue(
+ "expected bullet-list html, got ${sent.bodyHtml}",
+ sent.bodyHtml?.contains("") == true,
+ )
+ }
+
+ @Test
+ fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
+ setContent()
+
+ // Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
+ // a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
+ // label (there is no separate contentDescription) to announce what the button does.
+ val buttons = listOf(
+ "B" to R.string.format_bold,
+ "I" to R.string.format_italic,
+ "U" to R.string.format_underline,
+ "•" to R.string.format_bullet_list,
+ "1." to R.string.format_numbered_list,
+ "❝" to R.string.format_quote,
+ "🔗" to R.string.format_link,
+ )
+ buttons.forEach { (glyph, descriptionRes) ->
+ val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
+ val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
+ config[SemanticsActions.OnClick].label
+ } else {
+ null
+ }
+ val message = "toolbar button \"$glyph\" is missing its accessibility label"
+ assertEquals(message, string(descriptionRes), clickLabel)
+ }
+ }
+
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
}
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 942af0b..4f30d49 100644
--- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
+++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt
@@ -297,7 +297,7 @@ internal fun applyStyle(
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
-private fun applyBlock(
+internal fun applyBlock(
value: TextFieldValue,
marker: BlockMarker,
linkColor: Color,
@@ -315,7 +315,7 @@ private fun applyBlock(
)
}
-private fun applyLink(
+internal fun applyLink(
value: TextFieldValue,
url: String,
linkColor: Color,
diff --git a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt
new file mode 100644
index 0000000..38414a9
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt
@@ -0,0 +1,281 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.compose
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.sp
+import org.junit.Test
+import org.libremail.richtext.BlockMarker
+import org.libremail.richtext.RichAlign
+import org.libremail.richtext.RichAlignment
+import org.libremail.richtext.RichBaseStyle
+import org.libremail.richtext.RichImage
+import org.libremail.richtext.RichLink
+import org.libremail.richtext.RichSpan
+import org.libremail.richtext.RichStyle
+import org.libremail.richtext.RichTextContent
+import org.libremail.richtext.RichTextEditing
+import org.libremail.richtext.imageToken
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Unit tests for the Compose-editor glue in `RichTextEditor.kt`: the [TextFieldValue] <->
+ * [RichTextContent] conversions, the toolbar's editing ops ([applyStyle]/[applyBlock]/[applyLink]),
+ * and the active/inactive predicate `FormattingToolbar` uses to light up its buttons. Everything
+ * exercised here operates on plain [TextFieldValue]/[androidx.compose.ui.text.AnnotatedString]/[Color]
+ * values, so it all runs on the JVM - no emulator, no Compose runtime needed.
+ */
+class RichTextEditorTest {
+
+ private val linkColor = Color(0xFF3355FF)
+ private val noFont: (String) -> FontFamily? = { null }
+
+ private fun field(text: String, selection: TextRange = TextRange(text.length)) = TextFieldValue(text, selection)
+
+ // --- RichTextContent.toAnnotatedString() <-> AnnotatedString.toRichContent() round trip ---
+
+ @Test
+ fun `simple styles round-trip through spanStyles`() {
+ val content = RichTextContent(
+ text = "bold ital under struck",
+ spans = listOf(
+ RichSpan(0, 4, RichStyle.Bold),
+ RichSpan(5, 9, RichStyle.Italic),
+ RichSpan(10, 15, RichStyle.Underline),
+ RichSpan(16, 23, RichStyle.Strikethrough),
+ ),
+ )
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.text, restored.text)
+ assertEquals(content.spans.toSet(), restored.spans.toSet())
+ }
+
+ @Test
+ fun `parameterized styles on the same run round-trip through the STYLE_TAG annotation`() {
+ val content = RichTextContent(
+ text = "styled",
+ spans = listOf(
+ RichSpan(0, 6, RichStyle.FontFamily("Georgia, serif")),
+ RichSpan(0, 6, RichStyle.FontSize(18)),
+ RichSpan(0, 6, RichStyle.FontColor(0xFFCC0000.toInt())),
+ RichSpan(0, 6, RichStyle.Highlight(0xFFFFFF00.toInt())),
+ ),
+ )
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.spans.toSet(), restored.spans.toSet())
+ }
+
+ @Test
+ fun `links round-trip and their paint never masquerades as a font color span`() {
+ val content = RichTextContent("see here", links = listOf(RichLink(4, 8, "http://example.com")))
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.links, restored.links)
+ // The link's own SpanStyle(color = linkColor) must not decode back as a RichStyle.FontColor span
+ // (simpleStyleOf only recognizes the simple toggle styles; parameterized styles need STYLE_TAG).
+ assertTrue(restored.spans.isEmpty(), restored.spans.toString())
+ }
+
+ @Test
+ fun `images round-trip through the IMAGE_TAG annotation`() {
+ val token = imageToken("cat.png")
+ val content = RichTextContent(
+ text = token,
+ images = listOf(RichImage(0, token.length, "img1@libremail", "cat.png")),
+ )
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.images, restored.images)
+ }
+
+ @Test
+ fun `alignments round-trip through paragraph styles`() {
+ val content = RichTextContent("left\ncentered", alignments = listOf(RichAlignment(5, 13, RichAlign.CENTER)))
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.alignments, restored.alignments)
+ }
+
+ @Test
+ fun `baseStyle is position-independent so it must be threaded back in explicitly`() {
+ val base = RichBaseStyle(fontCss = "Arial, sans-serif", fontSizePt = 12)
+ val annotated = RichTextContent("plain", baseStyle = base).toAnnotatedString(linkColor, noFont)
+ // toAnnotatedString never encodes baseStyle into the AnnotatedString itself...
+ assertEquals(RichTextContent("plain"), annotated.toRichContent(baseStyle = null))
+ // ...toRichContent only carries it because the caller (RichTextBodyField) passes it back in.
+ assertEquals(base, annotated.toRichContent(baseStyle = base).baseStyle)
+ }
+
+ @Test
+ fun `resolveFont maps a FontFamily span's css to a display font without losing the css on replay`() {
+ val cursive = FontFamily.Cursive
+ val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.FontFamily("cursive-css"))))
+ val annotated = content.toAnnotatedString(linkColor) { css -> cursive.takeIf { css == "cursive-css" } }
+ assertEquals(cursive, annotated.spanStyles.single().item.fontFamily)
+ // Resolution is display-only: the model's css string survives regardless of whether it resolved.
+ assertEquals(content.spans, annotated.toRichContent().spans)
+ }
+
+ @Test
+ fun `a mixed run keeps every channel (style, link, alignment, image) distinct on the round trip`() {
+ val content = RichTextContent(
+ text = "• item\nnote [image: dog.png]",
+ spans = listOf(RichSpan(2, 6, RichStyle.Bold), RichSpan(7, 11, RichStyle.FontSize(10))),
+ links = listOf(RichLink(7, 11, "http://example.com")),
+ alignments = listOf(RichAlignment(0, 6, RichAlign.CENTER)),
+ images = listOf(RichImage(12, 28, "dog@mail", "dog.png")),
+ )
+ val restored = content.toAnnotatedString(linkColor, noFont).toRichContent()
+ assertEquals(content.text, restored.text)
+ assertEquals(content.spans.toSet(), restored.spans.toSet())
+ assertEquals(content.links, restored.links)
+ assertEquals(content.alignments, restored.alignments)
+ assertEquals(content.images, restored.images)
+ }
+
+ // --- applyStyle ---
+
+ @Test
+ fun `applyStyle toggles bold over the selection and keeps the selection unchanged`() {
+ val value = field("hello", TextRange(0, 5))
+ val bolded = applyStyle(value, RichStyle.Bold, linkColor)
+ assertEquals(TextRange(0, 5), bolded.selection)
+ assertEquals(listOf(RichSpan(0, 5, RichStyle.Bold)), bolded.annotatedString.toRichContent().spans)
+
+ val plain = applyStyle(bolded, RichStyle.Bold, linkColor)
+ assertTrue(plain.annotatedString.toRichContent().spans.isEmpty())
+ }
+
+ @Test
+ fun `applyStyle replaces a different value of the same parameterized kind`() {
+ val value = field("sized", TextRange(0, 5))
+ val small = applyStyle(value, RichStyle.FontSize(12), linkColor)
+ val big = applyStyle(small, RichStyle.FontSize(24), linkColor)
+ assertEquals(listOf(RichSpan(0, 5, RichStyle.FontSize(24))), big.annotatedString.toRichContent().spans)
+ }
+
+ @Test
+ fun `applyStyle is a no-op with a collapsed selection`() {
+ val value = field("hello", TextRange(2))
+ val result = applyStyle(value, RichStyle.Bold, linkColor)
+ assertTrue(result.annotatedString.toRichContent().spans.isEmpty())
+ }
+
+ // --- applyBlock ---
+
+ @Test
+ fun `applyBlock adds a bullet at the caret and shifts the selection past it`() {
+ val value = field("ab", TextRange(0))
+ val result = applyBlock(value, BlockMarker.BULLET, linkColor, noFont)
+ assertEquals("• ab", result.annotatedString.text)
+ assertEquals(TextRange(2), result.selection)
+ }
+
+ @Test
+ fun `applyBlock removes the marker on a second toggle`() {
+ val value = field("ab", TextRange(0))
+ val bulleted = applyBlock(value, BlockMarker.BULLET, linkColor, noFont)
+ val plain = applyBlock(bulleted.copy(selection = TextRange(0)), BlockMarker.BULLET, linkColor, noFont)
+ assertEquals("ab", plain.annotatedString.text)
+ }
+
+ @Test
+ fun `applyBlock replaces a different marker across a multi-line selection`() {
+ val value = field("• a\n• b", TextRange(0, 7))
+ val result = applyBlock(value, BlockMarker.ORDERED, linkColor, noFont)
+ assertEquals("1. a\n2. b", result.annotatedString.text)
+ }
+
+ // --- applyLink ---
+
+ @Test
+ fun `applyLink annotates the selection with the url and paints it in the link color`() {
+ val value = field("see here", TextRange(4, 8))
+ val linked = applyLink(value, "http://example.com", linkColor, noFont)
+ val content = linked.annotatedString.toRichContent()
+ assertEquals(listOf(RichLink(4, 8, "http://example.com")), content.links)
+ assertEquals(SpanStyle(color = linkColor), linked.annotatedString.spanStyles.single().item)
+ }
+
+ @Test
+ fun `applyLink is a no-op without a selection`() {
+ val value = field("see here", TextRange(4))
+ val result = applyLink(value, "http://example.com", linkColor, noFont)
+ assertTrue(result.annotatedString.toRichContent().links.isEmpty())
+ }
+
+ // --- applyBaseStyle ---
+
+ @Test
+ fun `applyBaseStyle overlays the message-wide font family and size`() {
+ val cursive = FontFamily.Cursive
+ val base = RichBaseStyle(fontCss = "cursive-css", fontSizePt = 18)
+ val result = applyBaseStyle(TextStyle(fontSize = 14.sp), base) { css ->
+ cursive.takeIf { css == "cursive-css" }
+ }
+ assertEquals(cursive, result.fontFamily)
+ assertEquals(18.sp, result.fontSize)
+ }
+
+ @Test
+ fun `applyBaseStyle is a no-op when there is no base style`() {
+ val original = TextStyle(fontSize = 14.sp)
+ assertEquals(original, applyBaseStyle(original, null, noFont))
+ }
+
+ // --- FormattingToolbar's active/inactive toggle state ---
+ // FormattingToolbar lights up a button with exactly `RichTextEditing.isStyled`/`hasBlock` over the
+ // field's current selection (see RichTextEditor.kt); these tests drive that same call through the
+ // TextFieldValue produced by the toolbar's own editing ops, so they pin the toggle behavior a user
+ // actually sees without needing to compose the toolbar itself.
+
+ @Test
+ fun `bold toggle state flips as the toolbar would read it after each tap`() {
+ var value = field("hello world", TextRange(0, 5))
+ assertFalse(isBoldActive(value))
+
+ value = applyStyle(value, RichStyle.Bold, linkColor)
+ assertTrue(isBoldActive(value))
+
+ // A selection that spans past the bold run is a mixed selection - inactive, like isStyled reports.
+ assertFalse(isBoldActive(value.copy(selection = TextRange(0, 11))))
+
+ value = applyStyle(value, RichStyle.Bold, linkColor)
+ assertFalse(isBoldActive(value))
+ }
+
+ @Test
+ fun `bullet toggle state is active only once every touched line carries the marker`() {
+ val start = field("a\nb", TextRange(0))
+ assertFalse(isBulletActive(start.copy(selection = TextRange(0, 3))))
+
+ val firstLineOnly = applyBlock(start, BlockMarker.BULLET, linkColor, noFont)
+ assertEquals("• a\nb", firstLineOnly.annotatedString.text)
+ val wholeText = TextRange(0, firstLineOnly.annotatedString.length)
+ assertFalse(isBulletActive(firstLineOnly.copy(selection = wholeText)))
+
+ val bothLines = applyBlock(firstLineOnly.copy(selection = wholeText), BlockMarker.BULLET, linkColor, noFont)
+ assertEquals("• a\n• b", bothLines.annotatedString.text)
+ val fullText = TextRange(0, bothLines.annotatedString.length)
+ assertTrue(isBulletActive(bothLines.copy(selection = fullText)))
+ }
+
+ /** Mirrors exactly what FormattingToolbar reads to decide a style button's active/inactive tint. */
+ private fun isBoldActive(value: TextFieldValue): Boolean = RichTextEditing.isStyled(
+ value.annotatedString.toRichContent(),
+ value.selection.min,
+ value.selection.max,
+ RichStyle.Bold,
+ )
+
+ /** Mirrors exactly what FormattingToolbar reads to decide a block button's active/inactive tint. */
+ private fun isBulletActive(value: TextFieldValue): Boolean = RichTextEditing.hasBlock(
+ value.annotatedString.toRichContent(),
+ value.selection.min,
+ value.selection.max,
+ BlockMarker.BULLET,
+ )
+}