feat(compose): paragraph alignment #198

Merged
JMR-dev merged 2 commits from feat-76-paragraph-alignment into main 2026-07-03 05:08:41 +00:00
7 changed files with 387 additions and 6 deletions
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.richtext.RichAlign
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the compose formatting toolbar's three-state paragraph-alignment control (#76).
* [ParagraphAlignmentControl] is presentational, so it is driven in isolation - independent of the
* surrounding [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
* exercises its picker.
*/
@RunWith(AndroidJUnit4::class)
class ParagraphAlignmentControlTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun setContent(selected: RichAlign?, onSelect: (RichAlign) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ParagraphAlignmentControl(selected = selected, onSelect = onSelect)
}
}
}
@Test
fun showsAllThreeAlignmentGlyphs() {
setContent(selected = RichAlign.START)
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).assertIsDisplayed()
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).assertIsDisplayed()
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).assertIsDisplayed()
}
@Test
fun tappingCenter_reportsCenter() {
var picked: RichAlign? = null
setContent(selected = RichAlign.START) { picked = it }
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).performClick()
assertEquals(RichAlign.CENTER, picked)
}
@Test
fun tappingEnd_reportsEnd() {
var picked: RichAlign? = null
setContent(selected = RichAlign.START) { picked = it }
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).performClick()
assertEquals(RichAlign.END, picked)
}
@Test
fun tappingStart_reportsStart() {
var picked: RichAlign? = null
setContent(selected = RichAlign.CENTER) { picked = it }
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).performClick()
assertEquals(RichAlign.START, picked)
}
}
@@ -106,6 +106,41 @@ object RichTextEditing {
)
return EditResult(updated, remap(start), remap(end))
}
/**
* Sets paragraph [align] on every line the selection [[start], [end]] touches, replacing whatever
* alignment those lines carried and leaving untouched paragraphs alone. [RichAlign.START] is the
* writing-direction default, so it is stored as *no* alignment (the range is dropped) — keeping an
* otherwise-plain paragraph plaintext-only — while CENTER and END become explicit ranges. The
* result is in the same canonical form [RichTextHtml.fromHtml] returns (one merged range per run
* of adjacent same-aligned lines; blank paragraphs never anchor a range, since the HTML model
* cannot pin a `text-align` to an empty `<p>`), so the model, its HTML, and the editor's
* [ParagraphStyle] rendering never drift.
*/
fun setAlignment(content: RichTextContent, start: Int, end: Int, align: RichAlign): RichTextContent {
val touched = lineStartsTouching(content.text, start, end).toHashSet()
val perLine = lineRanges(content.text).map { line ->
val effective = if (line.start in touched) {
align.takeUnless { it == RichAlign.START }
} else {
alignCovering(content.alignments, line)
}
line to effective
}
return content.copy(alignments = canonicalAlignments(perLine))
}
/**
* The single alignment shared by every paragraph the selection touches, or null when they are
* mixed. Unaligned paragraphs read as [RichAlign.START] (the default), so this drives the toolbar's
* three-state start/center/end control directly (null lights up none of the three).
*/
fun alignmentAt(content: RichTextContent, start: Int, end: Int): RichAlign? {
val aligns = lineStartsTouching(content.text, start, end).map { lineStart ->
alignCovering(content.alignments, lineRangeAt(content.text, lineStart)) ?: RichAlign.START
}
return aligns.distinct().singleOrNull()
}
}
private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
@@ -199,6 +234,61 @@ private fun lineStartsTouching(text: String, start: Int, end: Int): List<Int> {
return result
}
// --- paragraph alignment helpers ---
/** One line's half-open range [[start], [contentEnd]) of plain text (marker included, newline excluded). */
private data class LineRange(val start: Int, val contentEnd: Int)
private fun lineRanges(text: String): List<LineRange> {
val result = ArrayList<LineRange>()
var lineStart = 0
while (true) {
val nl = text.indexOf('\n', lineStart)
result.add(LineRange(lineStart, if (nl == -1) text.length else nl))
if (nl == -1) break
lineStart = nl + 1
}
return result
}
private fun lineRangeAt(text: String, lineStart: Int): LineRange {
val nl = text.indexOf('\n', lineStart)
return LineRange(lineStart, if (nl == -1) text.length else nl)
}
/**
* The alignment covering [line], mirroring how [RichTextHtml] picks a line's alignment on emit. An
* empty line gets a one-char probe so a range that spans it is still detected.
*/
private fun alignCovering(alignments: List<RichAlignment>, line: LineRange): RichAlign? =
alignments.firstOrNull { it.start < maxOf(line.contentEnd, line.start + 1) && it.end > line.start }?.align
/**
* Rebuilds canonical alignment ranges from a per-line alignment: one merged range per run of adjacent
* non-empty same-aligned lines. Empty paragraphs cannot carry a `text-align` in the HTML model, so
* they anchor no range and break a run — the exact form [RichTextHtml.fromHtml] returns.
*/
private fun canonicalAlignments(perLine: List<Pair<LineRange, RichAlign?>>): List<RichAlignment> {
val result = ArrayList<RichAlignment>()
var i = 0
while (i < perLine.size) {
val (line, align) = perLine[i]
if (align == null || line.start >= line.contentEnd) {
i++
continue
}
var j = i
while (j + 1 < perLine.size && perLine[j + 1].second == align && perLine[j + 1].first.isNotEmpty()) {
j++
}
result.add(RichAlignment(line.start, perLine[j].first.contentEnd, align))
i = j + 1
}
return result
}
private fun LineRange.isNotEmpty(): Boolean = start < contentEnd
private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String)
/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */
@@ -59,6 +59,7 @@ import org.libremail.richtext.RichTextHtml
import org.libremail.ui.compose.format.ColorSwatch
import org.libremail.ui.compose.format.ColorSwatchRow
import org.libremail.ui.compose.format.FontSizePicker
import org.libremail.ui.compose.format.ParagraphAlignmentControl
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
private const val URL_TAG = "libremail:url"
@@ -75,17 +76,19 @@ internal const val IMAGE_TAG = "libremail:image"
/**
* A rich-text body editor: a formatting toolbar (bold / italic / underline / strikethrough, font
* size, 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.
* size, font color and highlight, bulleted + numbered lists, block quote, link, and paragraph
* alignment) 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; the font
* size button opens the self-contained [FontSizePicker] dropdown.
* size button opens the self-contained [FontSizePicker] dropdown and the trailing
* [ParagraphAlignmentControl] carries the three alignment buttons.
*
* [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).
@@ -145,6 +148,7 @@ fun RichTextBodyField(
},
)
},
onAlignment = { align -> emit(applyAlignment(value, align, linkColor, resolveFont)) },
)
OutlinedTextField(
value = value,
@@ -229,6 +233,7 @@ private fun FormattingToolbar(
onFontColor: () -> Unit,
onHighlight: () -> Unit,
onFontSize: (Int?) -> Unit,
onAlignment: (RichAlign) -> Unit,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
@@ -314,9 +319,13 @@ private fun FormattingToolbar(
// *without* scrolling first (see ComposeScreenTest.formattingToolbar_bulletButtonMarksTheLine...),
// so its click lands on the button's on-screen center. Any control inserted *before* the block
// buttons shifts them right and can push the bullet past the viewport, making that tap miss —
// so this wider control is appended last, leaving every pre-existing button in its tested spot.
// so these wider controls are appended last, leaving every pre-existing button in its tested spot.
val fontSizePt = RichTextEditing.styleAt(content, start, end, RichStyle.FontSize::class.java)?.pt
FontSizePicker(selectedPt = fontSizePt, onSelect = onFontSize)
ParagraphAlignmentControl(
selected = RichTextEditing.alignmentAt(content, start, end),
onSelect = onAlignment,
)
}
}
@@ -493,6 +502,25 @@ internal fun applyLink(
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
/**
* Sets paragraph [align] over the selection and rebuilds the field value. Alignment never changes the
* text, so (unlike [applyBlock]) the selection is preserved as-is.
*/
internal fun applyAlignment(
value: TextFieldValue,
align: RichAlign,
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): TextFieldValue {
val updated = RichTextEditing.setAlignment(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
align,
)
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:
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.libremail.R
import org.libremail.richtext.RichAlign
/** The glyphs the three alignment buttons show; shared with the control's test so they can't drift. */
internal const val ALIGN_START_GLYPH = "⇤"
internal const val ALIGN_CENTER_GLYPH = "↔"
internal const val ALIGN_END_GLYPH = "⇥"
/**
* A three-state paragraph-alignment control (start / center / end) for the formatting toolbar. Each
* button is a bare glyph whose accessible meaning rides on its `onClickLabel` (there is no separate
* contentDescription), matching the toolbar's other buttons. [selected] lights up the matching button
* — pass [RichTextEditing.alignmentAt]'s result, where null (a mixed selection) lights up none — and
* [onSelect] reports the tapped alignment so the caller routes it through `setAlignment`.
*/
@Composable
fun ParagraphAlignmentControl(selected: RichAlign?, onSelect: (RichAlign) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AlignButton(
glyph = ALIGN_START_GLYPH,
description = stringResource(R.string.format_align_start),
active = selected == RichAlign.START,
onClick = { onSelect(RichAlign.START) },
)
AlignButton(
glyph = ALIGN_CENTER_GLYPH,
description = stringResource(R.string.format_align_center),
active = selected == RichAlign.CENTER,
onClick = { onSelect(RichAlign.CENTER) },
)
AlignButton(
glyph = ALIGN_END_GLYPH,
description = stringResource(R.string.format_align_end),
active = selected == RichAlign.END,
onClick = { onSelect(RichAlign.END) },
)
}
}
@Composable
private fun AlignButton(glyph: String, description: String, active: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(if (active) colors.secondaryContainer else Color.Transparent)
.clickable(onClick = onClick, role = Role.Button, onClickLabel = description)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(text = glyph, color = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant)
}
}
+3
View File
@@ -124,6 +124,9 @@
<string name="format_size">Font size</string>
<string name="format_size_default">Default</string>
<string name="format_size_pt">%1$d pt</string>
<string name="format_align_start">Align start</string>
<string name="format_align_center">Align center</string>
<string name="format_align_end">Align end</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -136,4 +136,100 @@ class RichTextEditingTest {
assertEquals(listOf(RichImage(5, 15, "c1", "x")), result.content.images)
assertEquals(base.baseStyle, result.content.baseStyle)
}
// --- setAlignment / alignmentAt ---
@Test
fun `setAlignment centers the caret's paragraph and start clears it back to default`() {
val centered = RichTextEditing.setAlignment(RichTextContent("hello"), 2, 2, RichAlign.CENTER)
assertEquals(listOf(RichAlignment(0, 5, RichAlign.CENTER)), centered.alignments)
// START is the writing-direction default, so it is stored as "no alignment" (range dropped).
val cleared = RichTextEditing.setAlignment(centered, 0, 5, RichAlign.START)
assertTrue(cleared.alignments.isEmpty())
}
@Test
fun `setAlignment over a multi-paragraph selection makes one merged range`() {
val result = RichTextEditing.setAlignment(RichTextContent("a\nb\nc"), 0, 5, RichAlign.END)
assertEquals(listOf(RichAlignment(0, 5, RichAlign.END)), result.alignments)
}
@Test
fun `setAlignment on a middle paragraph splits an existing block`() {
val base = RichTextContent("a\nb\nc", alignments = listOf(RichAlignment(0, 5, RichAlign.CENTER)))
// Select only the "b" line (positions 2..3) and right-align it.
val result = RichTextEditing.setAlignment(base, 2, 3, RichAlign.END)
assertEquals(
listOf(
RichAlignment(0, 1, RichAlign.CENTER),
RichAlignment(2, 3, RichAlign.END),
RichAlignment(4, 5, RichAlign.CENTER),
),
result.alignments,
)
}
@Test
fun `setAlignment leaves paragraphs the selection does not touch alone`() {
val base = RichTextContent("a\nb", alignments = listOf(RichAlignment(2, 3, RichAlign.END)))
val result = RichTextEditing.setAlignment(base, 0, 1, RichAlign.CENTER)
assertEquals(
listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(2, 3, RichAlign.END)),
result.alignments,
)
}
@Test
fun `setAlignment across a blank line does not anchor alignment to the blank paragraph`() {
// The blank middle paragraph cannot carry a text-align in the HTML model, so it breaks the run
// into two ranges — the canonical form RichTextHtml.fromHtml also returns.
val result = RichTextEditing.setAlignment(RichTextContent("a\n\nb"), 0, 4, RichAlign.CENTER)
assertEquals(
listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(3, 4, RichAlign.CENTER)),
result.alignments,
)
}
@Test
fun `setAlignment on an empty document is a no-op`() {
assertTrue(RichTextEditing.setAlignment(RichTextContent(""), 0, 0, RichAlign.CENTER).alignments.isEmpty())
}
@Test
fun `setAlignment output round-trips through html unchanged`() {
listOf(
RichTextEditing.setAlignment(RichTextContent("a\nb\nc"), 0, 5, RichAlign.END),
RichTextEditing.setAlignment(RichTextContent("a\n\nb"), 0, 4, RichAlign.CENTER),
RichTextEditing.setAlignment(
RichTextContent("a\nb\nc", alignments = listOf(RichAlignment(0, 5, RichAlign.CENTER))),
2,
3,
RichAlign.END,
),
).forEach { content ->
val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(content))
assertEquals(content.text, restored.text, "text of $content")
assertEquals(content.alignments, restored.alignments, "alignments of $content")
}
}
@Test
fun `alignmentAt reports the shared alignment, START default, or null when mixed`() {
val content = RichTextContent(
"a\nb\nc",
alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(2, 3, RichAlign.END)),
)
assertEquals(RichAlign.CENTER, RichTextEditing.alignmentAt(content, 0, 1))
assertEquals(RichAlign.END, RichTextEditing.alignmentAt(content, 2, 3))
// "c" carries no explicit alignment, so it reads as the START default.
assertEquals(RichAlign.START, RichTextEditing.alignmentAt(content, 4, 5))
// A selection spanning center + end paragraphs is mixed.
assertNull(RichTextEditing.alignmentAt(content, 0, 3))
}
@Test
fun `alignmentAt treats a plain paragraph as START`() {
assertEquals(RichAlign.START, RichTextEditing.alignmentAt(RichTextContent("plain"), 0, 5))
}
}
@@ -272,6 +272,26 @@ class RichTextEditorTest {
assertTrue(result.annotatedString.toRichContent().links.isEmpty())
}
// --- applyAlignment ---
@Test
fun `applyAlignment centers the selection's paragraphs and keeps the selection`() {
val value = field("a\nb", TextRange(0, 3))
val result = applyAlignment(value, RichAlign.CENTER, linkColor, noFont)
assertEquals(TextRange(0, 3), result.selection)
assertEquals(
listOf(RichAlignment(0, 3, RichAlign.CENTER)),
result.annotatedString.toRichContent().alignments,
)
}
@Test
fun `applyAlignment start clears an existing alignment`() {
val centered = applyAlignment(field("hello", TextRange(0, 5)), RichAlign.CENTER, linkColor, noFont)
val cleared = applyAlignment(centered, RichAlign.START, linkColor, noFont)
assertTrue(cleared.annotatedString.toRichContent().alignments.isEmpty())
}
// --- applyBaseStyle ---
@Test