Merge main into feat-177-draft-autosave

This commit is contained in:
Jason Ross
2026-07-03 00:45:19 -05:00
committed by GitHub
21 changed files with 1054 additions and 6 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)
}
}
@@ -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. */
@@ -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
@@ -191,6 +192,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,7 +58,9 @@ 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
/** 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 +77,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.
* 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.
*
* 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 +149,16 @@ 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,
@@ -229,6 +243,8 @@ private fun FormattingToolbar(
onFontColor: () -> Unit,
onHighlight: () -> Unit,
onFontSize: (Int?) -> Unit,
onAlignment: (RichAlign) -> Unit,
onFont: (String?) -> Unit,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
@@ -314,9 +330,15 @@ 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 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(
selected = RichTextEditing.alignmentAt(content, start, end),
onSelect = onAlignment,
)
}
}
@@ -493,6 +515,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,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
}
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -124,6 +124,11 @@
<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>
<string name="format_font">Font</string>
<string name="format_font_default">Default</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
@@ -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"))
}
}