feat(compose): add a font family picker with bundled open-source fonts

Bundle four SIL OFL 1.1 fonts in res/font (no build-time downloads, F-Droid
safe): Inter, Lora, and JetBrains Mono as weight-variable TTFs plus a static
Merriweather Regular cut (its variable font is 4.4 MB) — ~1.5 MB total. Each
family's OFL license text is committed under THIRD_PARTY_LICENSES/, and *.ttf/
*.otf are marked binary in .gitattributes so the bytes commit intact.

Add FontRegistry: display name <-> email-safe CSS stack <-> Compose FontFamily,
with three generic Sans/Serif/Monospace entries that need no bundled file. Each
bundled stack names the family first then falls back to a generic (e.g.
'Lora', Georgia, serif), so a recipient whose client lacks the face still gets
a sensible one. resolveFontFamily maps a stored CSS stack back to a FontFamily
for in-editor rendering, and is now passed into RichTextBodyField from
ComposeScreen so styled runs actually render in their font.

Add FontPicker (ui/compose/format): a toolbar dropdown applying
RichStyle.FontFamily(css) via the generalized applyStyle/clearStyle path, with a
leading "Default" entry that clears it; each menu entry previews itself in its
own face. Appended at the END of the toolbar (with the size/alignment controls),
after the block and link buttons — per the #73/#76 lesson, nothing may shift the
bullet/numbered/quote buttons that the compose E2E taps without scrolling.

Tests: FontRegistryTest (JVM) proves every CSS stack survives an html
round-trip, the resolver maps known/unknown stacks, and bundled stacks end in a
generic fallback; a compile-only FontPickerTest drives the picker in isolation
(default label, current-font label, menu lists every font, picking reports the
css / Default clears).

Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:31:09 -05:00
co-authored by Claude Opus 4.8
parent e3d54aadd2
commit 071034de65
16 changed files with 670 additions and 3 deletions
+2
View File
@@ -9,6 +9,8 @@ gradlew text eol=lf
# Treat these as binary.
*.jar binary
*.ttf binary
*.otf binary
*.png binary
*.jpg binary
*.jpeg binary
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -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-family picker (#72). [FontPicker] is
* presentational, so it is driven in isolation - independent of the surrounding
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
* exercises its dropdown.
*/
@RunWith(AndroidJUnit4::class)
class FontPickerTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(selectedCss: String?, onSelect: (String?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
FontPicker(selectedCss = selectedCss, onSelect = onSelect)
}
}
}
@Test
fun noFontSelected_buttonShowsDefaultLabel() {
setContent(selectedCss = null)
composeTestRule.onNodeWithText(string(R.string.format_font_default)).assertIsDisplayed()
}
@Test
fun aFontSelected_buttonShowsItsDisplayName() {
val inter = FontRegistry.choices.first { it.name == "Inter" }
setContent(selectedCss = inter.css)
composeTestRule.onNodeWithText("Inter").assertIsDisplayed()
}
@Test
fun tappingTheButton_opensAMenuListingEveryRegistryFont() {
setContent(selectedCss = null)
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
FontRegistry.choices.forEach { choice ->
composeTestRule.onNodeWithText(choice.name).assertIsDisplayed()
}
}
@Test
fun pickingAFontFromTheMenu_reportsItsCssStack() {
var picked: String? = "unset"
val lora = FontRegistry.choices.first { it.name == "Lora" }
setContent(selectedCss = null) { picked = it }
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
composeTestRule.onNodeWithText("Lora").performClick()
assertEquals(lora.css, picked)
}
@Test
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
var picked: String? = "unset"
val inter = FontRegistry.choices.first { it.name == "Inter" }
// The button reads "Inter" here, so the menu's own "Default" entry is the only such match.
setContent(selectedCss = inter.css) { picked = it }
composeTestRule.onNodeWithText("Inter").performClick()
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
assertEquals(null, picked)
}
}
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.collect
import org.libremail.R
import org.libremail.domain.model.Account
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.ui.compose.format.FontRegistry
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -186,6 +187,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
onBodyChange = viewModel::onBodyChange,
label = stringResource(R.string.compose_body),
modifier = Modifier.fillMaxWidth().weight(1f),
resolveFont = FontRegistry::resolveFontFamily,
)
}
@@ -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.FontPicker
import org.libremail.ui.compose.format.FontSizePicker
import org.libremail.ui.compose.format.ParagraphAlignmentControl
@@ -76,9 +77,9 @@ 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, 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
* family + 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.
*
@@ -149,6 +150,15 @@ fun RichTextBodyField(
)
},
onAlignment = { align -> emit(applyAlignment(value, align, linkColor, resolveFont)) },
onFont = { css ->
emit(
if (css != null) {
applyStyle(value, RichStyle.FontFamily(css), linkColor, resolveFont)
} else {
clearStyle(value, RichStyle.FontFamily::class.java, linkColor, resolveFont)
},
)
},
)
OutlinedTextField(
value = value,
@@ -234,6 +244,7 @@ private fun FormattingToolbar(
onHighlight: () -> Unit,
onFontSize: (Int?) -> Unit,
onAlignment: (RichAlign) -> Unit,
onFont: (String?) -> Unit,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
@@ -320,6 +331,8 @@ private fun FormattingToolbar(
// 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 these wider controls are appended last, leaving every pre-existing button in its tested spot.
val fontCss = RichTextEditing.styleAt(content, start, end, RichStyle.FontFamily::class.java)?.css
FontPicker(selectedCss = fontCss, onSelect = onFont)
val fontSizePt = RichTextEditing.styleAt(content, start, end, RichStyle.FontSize::class.java)?.pt
FontSizePicker(selectedPt = fontSizePt, onSelect = onFontSize)
ParagraphAlignmentControl(
@@ -0,0 +1,93 @@
// 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.LocalTextStyle
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
/**
* A toolbar dropdown for [org.libremail.richtext.RichStyle.FontFamily]. The anchor shows the
* selection's current font by display name (via [FontRegistry]), or "Default" when [selectedCss] is
* null (no font applied, or a mixed selection). The menu lists a leading "Default" entry that clears
* the style, then every [FontRegistry] choice rendered in its own face as a preview. Mirrors
* [FontSizePicker]'s "Default" convention: [onSelect] receives null to clear and a CSS stack
* otherwise, so the caller routes the choice straight through the generalized `applyStyle`/`clearStyle`
* path with no font-specific branching.
*/
@Composable
fun FontPicker(selectedCss: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
val colors = MaterialTheme.colorScheme
val label = when {
selectedCss == null -> stringResource(R.string.format_font_default)
else -> FontRegistry.displayNameFor(selectedCss) ?: selectedCss
}
val contentColor = if (selectedCss != null) colors.onSecondaryContainer else colors.onSurfaceVariant
Box(modifier = modifier) {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(if (selectedCss != null) colors.secondaryContainer else Color.Transparent)
.clickable(
onClick = { expanded = true },
role = Role.Button,
onClickLabel = stringResource(R.string.format_font),
)
.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_font_default)) },
onClick = {
expanded = false
onSelect(null)
},
)
FontRegistry.choices.forEach { choice ->
DropdownMenuItem(
// Each entry previews itself in its own face so the picker reads like a font menu.
text = { Text(choice.name, style = LocalTextStyle.current.copy(fontFamily = choice.fontFamily)) },
onClick = {
expanded = false
onSelect(choice.css)
},
)
}
}
}
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import org.libremail.R
/** One selectable font: a display [name], its email-safe CSS stack, and the Compose family to render it. */
data class FontChoice(val name: String, val css: String, val fontFamily: FontFamily)
/**
* The fonts the [FontPicker] offers and the resolver the editor renders them with. Three generic
* families need no bundled file (they map to the platform defaults and to bare CSS generics); four
* bundled open-source families (all SIL OFL 1.1 — Inter, Lora, Merriweather, JetBrains Mono, whose
* license texts live under `THIRD_PARTY_LICENSES/`) each name the family first in their CSS stack and
* fall back to a generic, so a recipient whose mail client lacks the bundled face still renders a
* sensible one. The stored [RichStyle.FontFamily] carries only the CSS string, so nothing here leaks
* into the wire format — [resolveFontFamily] just maps that string back to a Compose [FontFamily] for
* in-editor display (null → leave the system default).
*/
object FontRegistry {
val choices: List<FontChoice> = listOf(
FontChoice("Sans-serif", "sans-serif", FontFamily.SansSerif),
FontChoice("Serif", "serif", FontFamily.Serif),
FontChoice("Monospace", "monospace", FontFamily.Monospace),
FontChoice("Inter", "'Inter', sans-serif", FontFamily(Font(R.font.inter))),
FontChoice("Lora", "'Lora', Georgia, serif", FontFamily(Font(R.font.lora))),
FontChoice("Merriweather", "'Merriweather', Georgia, serif", FontFamily(Font(R.font.merriweather))),
FontChoice("JetBrains Mono", "'JetBrains Mono', monospace", FontFamily(Font(R.font.jetbrains_mono))),
)
private val byCss: Map<String, FontChoice> = choices.associateBy { it.css }
/** The Compose family for a stored CSS stack, or null when it isn't one of ours (→ system font). */
fun resolveFontFamily(css: String): FontFamily? = byCss[css]?.fontFamily
/** The display name for a stored CSS stack, or null when it isn't one of ours. */
fun displayNameFor(css: String): String? = byCss[css]?.name
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -127,6 +127,8 @@
<string name="format_align_start">Align start</string>
<string name="format_align_center">Align center</string>
<string name="format_align_end">Align end</string>
<string name="format_font">Font</string>
<string name="format_font_default">Default</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import org.junit.Test
import org.libremail.richtext.RichSpan
import org.libremail.richtext.RichStyle
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for [FontRegistry] — the font-family picker's source of truth. These run on the JVM:
* the registry's CSS stacks and lookups are plain data, so no emulator is needed to prove the wire
* format (the stored CSS) round-trips and that recipients fall back down the stack.
*/
class FontRegistryTest {
private val generics = setOf("sans-serif", "serif", "monospace")
@Test
fun `every css stack survives an html round-trip`() {
FontRegistry.choices.forEach { choice ->
val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.FontFamily(choice.css))))
val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(content))
assertEquals(content.spans, restored.spans, "round-trip of ${choice.name}")
}
}
@Test
fun `resolveFontFamily maps a known css stack and rejects an unknown one`() {
FontRegistry.choices.forEach { choice ->
assertNotNull(FontRegistry.resolveFontFamily(choice.css), "resolve ${choice.name}")
}
assertNull(FontRegistry.resolveFontFamily("'Nonexistent', fantasy"))
}
@Test
fun `bundled stacks name the family first then end in a generic fallback`() {
// A recipient whose client lacks the bundled face falls back down the CSS stack to a generic.
FontRegistry.choices.filter { it.css !in generics }.forEach { choice ->
val parts = choice.css.split(",").map { it.trim() }
assertTrue(parts.size >= 2, "${choice.name} needs at least one fallback")
assertTrue(parts.first().startsWith("'"), "${choice.name} must name the family first")
assertTrue(parts.last().lowercase() in generics, "${choice.name} must end in a generic family")
}
}
@Test
fun `the three generic families need no bundled file`() {
assertEquals(generics, FontRegistry.choices.map { it.css }.filter { it in generics }.toSet())
}
@Test
fun `displayNameFor resolves a known stack and is null otherwise`() {
val choice = FontRegistry.choices.last()
assertEquals(choice.name, FontRegistry.displayNameFor(choice.css))
assertNull(FontRegistry.displayNameFor("'Nonexistent', fantasy"))
}
}