feat(compose): add font size control to the formatting toolbar

Add a preset-size dropdown (10/12/14/18/24pt, plus Default to clear) to the
compose FormattingToolbar via a new FontSizePicker composable, applying
RichStyle.FontSize over the selection through the existing generalized
applyStyle/clearStyle toggle path (no font-size-specific branching needed).
The anchor button shows the selection's current size, or "Default" when
unset/mixed. The rich-text foundation already provided RichStyle.FontSize,
its pt/px-tolerant HTML round-trip, and pt->sp mapping for in-editor
rendering; this ticket wires up the missing UI control.

Closes #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:38:22 -05:00
co-authored by Claude Opus 4.8
parent 4872d24981
commit 23fa389c0a
5 changed files with 199 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
@@ -257,6 +269,8 @@ private fun FormattingToolbar(
strikethrough = true,
onClick = { onToggleStyle(RichStyle.Strikethrough) },
)
val fontSizePt = RichTextEditing.styleAt(content, start, end, RichStyle.FontSize::class.java)?.pt
FontSizePicker(selectedPt = fontSizePt, onSelect = onFontSize)
val fontColorArgb = RichTextEditing.styleAt(content, start, end, RichStyle.FontColor::class.java)?.argb
FormatButton(
label = "A",
@@ -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>
@@ -207,6 +207,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