Merge pull request #195 from JMR-dev/feat-73-font-size-control

feat(compose): font size control
This commit was merged in pull request #195.
This commit is contained in:
Jason Ross
2026-07-02 23:27:55 -05:00
committed by GitHub
5 changed files with 219 additions and 2 deletions
@@ -0,0 +1,81 @@
// 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.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the compose formatting toolbar's font-size dropdown (#73). [FontSizePicker] is
* presentational, so it is driven directly - independent of the surrounding
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `ContactAutocompleteRowTest`
* exercises its row composable in isolation.
*/
@RunWith(AndroidJUnit4::class)
class FontSizePickerTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
private fun setContent(selectedPt: Int?, onSelect: (Int?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
FontSizePicker(selectedPt = selectedPt, onSelect = onSelect)
}
}
}
@Test
fun noSizeSelected_buttonShowsDefaultLabel() {
setContent(selectedPt = null)
composeTestRule.onNodeWithText(string(R.string.format_size_default)).assertIsDisplayed()
}
@Test
fun aSizeSelected_buttonShowsItsPointValue() {
setContent(selectedPt = 18)
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 18)).assertIsDisplayed()
}
@Test
fun tappingTheButton_opensAMenuListingDefaultAndEveryPreset() {
setContent(selectedPt = null)
// Before the menu opens, "Default" only labels the anchor button itself - a unique match.
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
FONT_SIZE_PRESETS_PT.forEach { pt ->
composeTestRule.onNodeWithText(string(R.string.format_size_pt, pt)).assertIsDisplayed()
}
}
@Test
fun pickingAPresetFromTheMenu_reportsItsPointSize() {
var picked: Int? = -1
setContent(selectedPt = null) { picked = it }
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 14)).performClick()
assertEquals(14, picked)
}
@Test
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
var picked: Int? = 12
setContent(selectedPt = 12) { picked = it }
// The button reads "12 pt" here, so the menu's own "Default" entry is the only such match.
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 12)).performClick()
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
assertEquals(null, picked)
}
}
@@ -58,6 +58,7 @@ import org.libremail.richtext.RichTextEditing
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
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
private const val URL_TAG = "libremail:url"
@@ -74,7 +75,7 @@ internal const val IMAGE_TAG = "libremail:image"
/**
* 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
* 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.
@@ -83,7 +84,8 @@ internal const val IMAGE_TAG = "libremail:image"
* 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.
* [ColorSwatchRow], whose individual swatches carry their own `contentDescription` instead; the font
* size button opens the self-contained [FontSizePicker] dropdown.
*
* [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).
@@ -134,6 +136,15 @@ fun RichTextBodyField(
onLink = { showLinkDialog = true },
onFontColor = { showFontColorPicker = true },
onHighlight = { showHighlightPicker = true },
onFontSize = { pt ->
emit(
if (pt != null) {
applyStyle(value, RichStyle.FontSize(pt), linkColor, resolveFont)
} else {
clearStyle(value, RichStyle.FontSize::class.java, linkColor, resolveFont)
},
)
},
)
OutlinedTextField(
value = value,
@@ -217,6 +228,7 @@ private fun FormattingToolbar(
onLink: () -> Unit,
onFontColor: () -> Unit,
onHighlight: () -> Unit,
onFontSize: (Int?) -> Unit,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
@@ -297,6 +309,14 @@ private fun FormattingToolbar(
active = false,
onClick = onLink,
)
// The font-size dropdown trails every glyph button on purpose. The toolbar overflows the
// screen width and scrolls horizontally, and the compose E2E taps the "•" bullet button
// *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.
val fontSizePt = RichTextEditing.styleAt(content, start, end, RichStyle.FontSize::class.java)?.pt
FontSizePicker(selectedPt = fontSizePt, onSelect = onFontSize)
}
}
@@ -0,0 +1,91 @@
// 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.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
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
/** The fixed preset sizes [FontSizePicker] offers, in points. */
internal val FONT_SIZE_PRESETS_PT = listOf(10, 12, 14, 18, 24)
/**
* A toolbar dropdown for [org.libremail.richtext.RichStyle.FontSize]: the anchor button shows the
* selection's current size, or "Default" when [selectedPt] is null (no size applied, or a mixed
* selection), and opens a menu of [FONT_SIZE_PRESETS_PT] plus a leading "Default" entry that clears
* the style outright. Mirrors [ColorSwatchRow]'s "no color" convention: [onSelect] receives null for
* "Default" and a preset point size otherwise, so the caller routes the choice straight through the
* generalized `applyStyle`/`clearStyle` toggle path with no font-size-specific branching of its own.
*/
@Composable
fun FontSizePicker(selectedPt: Int?, onSelect: (Int?) -> Unit, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
val colors = MaterialTheme.colorScheme
val description = stringResource(R.string.format_size)
val label = if (selectedPt != null) {
stringResource(R.string.format_size_pt, selectedPt)
} else {
stringResource(R.string.format_size_default)
}
val contentColor = if (selectedPt != null) colors.onSecondaryContainer else colors.onSurfaceVariant
Box(modifier = modifier) {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(if (selectedPt != null) colors.secondaryContainer else Color.Transparent)
.clickable(onClick = { expanded = true }, role = Role.Button, onClickLabel = description)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = label, color = contentColor)
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.format_size_default)) },
onClick = {
expanded = false
onSelect(null)
},
)
FONT_SIZE_PRESETS_PT.forEach { pt ->
DropdownMenuItem(
text = { Text(stringResource(R.string.format_size_pt, pt)) },
onClick = {
expanded = false
onSelect(pt)
},
)
}
}
}
}
+3
View File
@@ -121,6 +121,9 @@
<string name="format_highlight_green">Green</string>
<string name="format_highlight_cyan">Cyan</string>
<string name="format_highlight_pink">Pink</string>
<string name="format_size">Font size</string>
<string name="format_size_default">Default</string>
<string name="format_size_pt">%1$d pt</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -19,6 +19,7 @@ import org.libremail.richtext.RichSpan
import org.libremail.richtext.RichStyle
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextEditing
import org.libremail.richtext.RichTextHtml
import org.libremail.richtext.imageToken
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -207,6 +208,14 @@ class RichTextEditorTest {
assertEquals(colored.annotatedString.toRichContent().spans, result.annotatedString.toRichContent().spans)
}
@Test
fun `clearStyle removes a font size span regardless of its value`() {
val value = field("hello", TextRange(0, 5))
val sized = applyStyle(value, RichStyle.FontSize(18), linkColor)
val cleared = clearStyle(sized, RichStyle.FontSize::class.java, linkColor)
assertTrue(cleared.annotatedString.toRichContent().spans.isEmpty())
}
// --- applyBlock ---
@Test
@@ -232,6 +241,19 @@ class RichTextEditorTest {
assertEquals("1. a\n2. b", result.annotatedString.text)
}
@Test
fun `applyBlock bullet on an end-of-text caret marks the line and serializes to ul li html`() {
// The JVM-layer twin of ComposeScreenTest.formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml:
// a bullet tap on the end-of-text caret that typing leaves must mark the whole line and serialize
// to a real list. Pinning it here catches a regression in the block-toggle/HTML flow without an
// emulator; the instrumented test additionally guards that the toolbar button stays tappable.
val value = field("Buy milk", TextRange(8))
val bulleted = applyBlock(value, BlockMarker.BULLET, linkColor, noFont)
val content = bulleted.annotatedString.toRichContent()
assertEquals("• Buy milk", content.text)
assertEquals("<ul><li>Buy milk</li></ul>", RichTextHtml.toHtml(content))
}
// --- applyLink ---
@Test