diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt new file mode 100644 index 0000000..cc6e281 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/FontSizePickerTest.kt @@ -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() + + 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) + } +} 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 0a3c6cd..71ad4a6 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -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", diff --git a/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt b/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt new file mode 100644 index 0000000..783643d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/format/FontSizePicker.kt @@ -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) + }, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8134527..8d49bb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,6 +121,9 @@ Green Cyan Pink + Font size + Default + %1$d pt Drafts diff --git a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt index 5af9e47..9eccb38 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/RichTextEditorTest.kt @@ -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