Rich compose: HTML editor + formatting toolbar #36

Closed
opened 2026-07-01 04:01:35 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-01 04:01:35 +00:00 (Migrated from github.com)

Part of #14.

Context

Compose is plaintext today; the reader already renders HTML (ui/reader/HtmlBody.kt). This
ticket brings rich composition to the editor. Sending is #37; signatures are #38.

Decision: custom Compose-native model, no third-party dependency

Considered three approaches for the editor/model itself:

  1. Custom Compose-native model — our own span/range data model
    (text + style/link/alignment/image ranges) rendered through BasicTextField/AnnotatedString,
    with a hand-written HTML serializer/parser. Zero third-party runtime dependency.
  2. A third-party Compose-native rich-text library (e.g. richeditor-compose, MIT) — same
    architecture as (1) but maintained upstream.
  3. WebView + contenteditable + a bundled open-source JS editor (Quill/TipTap-style) — the
    common cross-platform approach, distinct from the reader's existing WebView use (which is
    read-only, sandboxed HTML rendering, already audited under #15's checklist — editing inside a
    WebView is a materially different, harder-to-secure-and-test surface).

Decision: (1). Reasoning against the compliance constraints this app has to satisfy
(F-Droid, Play Store, GPL-3.0):

  • GPL-3.0: a fully custom, in-repo implementation needs no license reconciliation at all — it's
    already GPL-3.0-or-later like every other file here. (2) would also be fine license-wise
    (MIT is one-directionally GPL-compatible, same relationship already relied on for Apache-2.0
    AndroidX deps in docs/fdroid-compliance.md's license table), but (3) requires auditing whatever
    JS editor is bundled and staying strictly on its free-as-in-freedom core (avoiding "open-core"
    editors with a separate proprietary tier).
  • F-Droid: (1) adds zero new dependency and zero anti-feature surface — nothing to add to
    docs/fdroid-compliance.md's audit. (3) is only viable if the JS/CSS is bundled in assets/
    and loaded via file:///android_asset/, never fetched from a CDN at runtime (F-Droid's
    anti-feature policy is specifically about apps that download executable code over the network).
  • Play Store: no meaningful difference between the three options here.
  • Accessibility: this ticket's own acceptance criteria requires TalkBack + large-font support.
    A native Compose text field ((1) or (2)) inherits that for free; WebView-hosted
    contenteditable ((3)) is the weakest of the three on this axis.

Went with (1) over (2) mainly because the exact model needed (paragraph alignment, inline cid:
images, a message-wide base font, per-run parameterized styles) is specific to how this app's
outbox/reply-quoting/draft-persistence code already works (see #37's SmtpSender/GraphSender,
data/ReplyBuilder.kt) — building it in-repo means the model and the HTML shape it produces are
exactly what those consumers need, with no impedance mismatch to a library's own opinions.
Revisit (2) only if the hand-rolled parser/editor starts costing more maintenance time than
adopting a library would — not preemptively.

This is already substantially implemented on main (see Scope below) — this section documents
why, so the decision doesn't get silently re-litigated later.

Scope

  • Rich-text editor in ComposeScreen with a formatting toolbar — done:
    ui/compose/RichTextEditor.kt's RichTextBodyField/FormattingToolbar, wired into
    ComposeScreen.kt (~line 183). Bold/italic/underline, bulleted + numbered lists, block quote,
    and link are all present (format_bold … format_link_* strings). Headings were only
    "consider" in the original scope and are not implemented
    — see the gap below for the
    decision this ticket still needs.
  • Internal rich model that serializes to HTML for the message body — done:
    richtext/RichText.kt (RichTextContent, every RichStyle variant, RichAlignment,
    RichImage, RichBaseStyle, RichLink) + richtext/RichTextHtml.kt /
    RichTextHtmlParser.kt (hand-written round-trip, no parsing library). Model-layer tests exist:
    RichTextEditingTest.kt, RichTextHtmlTest.kt.
  • Preserve a smooth plaintext-only experience; keep it accessible (TalkBack, large fonts) —
    implemented (RichTextContent.hasFormatting() gates HTML emission so an unformatted message
    stays plaintext-only; toolbar buttons carry contentDescription/onClickLabel) but not yet
    verified by any test
    — see the gap below.
  • Round the compose input/container corners — done via #23 (closed): every OutlinedTextField
    in ComposeScreen.kt and RichTextBodyField already uses a rounded shape.

Remaining gaps

  • Decide on headings. Not implemented anywhere in the model or toolbar. Either add an
    H1/H2-style block marker (mirroring the existing bullet/numbered/quote BlockMarker pattern
    in richtext/RichTextEditing.kt) or explicitly drop it from scope so it stops being an
    ambiguous "consider" item. Low priority either way — call it out so it's a decision, not an
    oversight.
  • UI-layer test coverage for RichTextEditor.kt. Everything tested so far
    (RichTextEditingTest.kt, RichTextHtmlTest.kt) exercises the underlying model
    (RichTextEditing, RichTextHtml) — nothing tests the Compose-editor glue in
    RichTextEditor.kt itself: applyStyle/applyBlock/applyLink,
    AnnotatedString.toRichContent()/RichTextContent.toAnnotatedString(), or
    FormattingToolbar's active/inactive toggle state. These internal functions operate on
    TextFieldValue/AnnotatedString, which are plain Kotlin types usable directly in a JVM unit
    test — add a RichTextEditorTest.kt alongside the existing two. Separately, add an androidTest
    (ComposeScreenTest.kt) that taps a toolbar button and asserts the resulting content — this is
    the only way to actually verify the KDoc's accessibility claim (content descriptions, toggle
    state) rather than assume it.

Acceptance criteria

  • The user can apply formatting and it round-trips into the sent message — the #37 half of this
    is confirmed
    : SmtpSenderTest.kt (GreenMail-backed) verifies a formatted message sends as
    multipart/alternative (nested inside multipart/mixed when there's also an attachment), and
    ComposeViewModelTest.kt confirms bodyHtml stays null when nothing is formatted and carries
    the right HTML when it is. So this ticket is not blocked on #37 for closing.
  • Plaintext-only composing is unchanged in feel — behaviorally true by inspection
    (hasFormatting() gate) but not yet covered by a test at the editor layer; folded into the gap
    above.

Relevant files

  • ui/compose/ComposeScreen.kt, ui/compose/ComposeViewModel.kt, ui/compose/RichTextEditor.kt,
    richtext/RichText.kt, richtext/RichTextEditing.kt, richtext/RichTextHtml.kt,
    richtext/RichTextHtmlParser.kt.

Notes

Pairs with #37 (send path — confirmed implemented/tested, see above); folds in #23 (done).

Part of #14. ## Context Compose is plaintext today; the reader already renders HTML (`ui/reader/HtmlBody.kt`). This ticket brings rich composition to the editor. Sending is #37; signatures are #38. ## Decision: custom Compose-native model, no third-party dependency Considered three approaches for the editor/model itself: 1. **Custom Compose-native model** — our own span/range data model (`text` + style/link/alignment/image ranges) rendered through `BasicTextField`/`AnnotatedString`, with a hand-written HTML serializer/parser. Zero third-party runtime dependency. 2. **A third-party Compose-native rich-text library** (e.g. `richeditor-compose`, MIT) — same architecture as (1) but maintained upstream. 3. **WebView + `contenteditable` + a bundled open-source JS editor** (Quill/TipTap-style) — the common cross-platform approach, distinct from the reader's existing WebView use (which is read-only, sandboxed HTML *rendering*, already audited under #15's checklist — editing inside a WebView is a materially different, harder-to-secure-and-test surface). **Decision: (1).** Reasoning against the compliance constraints this app has to satisfy (F-Droid, Play Store, GPL-3.0): - **GPL-3.0**: a fully custom, in-repo implementation needs no license reconciliation at all — it's already `GPL-3.0-or-later` like every other file here. (2) would also be fine license-wise (MIT is one-directionally GPL-compatible, same relationship already relied on for Apache-2.0 AndroidX deps in `docs/fdroid-compliance.md`'s license table), but (3) requires auditing whatever JS editor is bundled and staying strictly on its free-as-in-freedom core (avoiding "open-core" editors with a separate proprietary tier). - **F-Droid**: (1) adds zero new dependency and zero anti-feature surface — nothing to add to `docs/fdroid-compliance.md`'s audit. (3) is only viable if the JS/CSS is bundled in `assets/` and loaded via `file:///android_asset/`, never fetched from a CDN at runtime (F-Droid's anti-feature policy is specifically about apps that download executable code over the network). - **Play Store**: no meaningful difference between the three options here. - **Accessibility**: this ticket's own acceptance criteria requires TalkBack + large-font support. A native Compose text field ((1) or (2)) inherits that for free; WebView-hosted `contenteditable` ((3)) is the weakest of the three on this axis. Went with (1) over (2) mainly because the exact model needed (paragraph alignment, inline `cid:` images, a message-wide base font, per-run parameterized styles) is specific to how this app's outbox/reply-quoting/draft-persistence code already works (see #37's `SmtpSender`/`GraphSender`, `data/ReplyBuilder.kt`) — building it in-repo means the model and the HTML shape it produces are exactly what those consumers need, with no impedance mismatch to a library's own opinions. **Revisit (2) only if the hand-rolled parser/editor starts costing more maintenance time than adopting a library would — not preemptively.** This is already substantially implemented on `main` (see Scope below) — this section documents *why*, so the decision doesn't get silently re-litigated later. ## Scope - [x] Rich-text editor in `ComposeScreen` with a formatting toolbar — done: `ui/compose/RichTextEditor.kt`'s `RichTextBodyField`/`FormattingToolbar`, wired into `ComposeScreen.kt` (~line 183). Bold/italic/underline, bulleted + numbered lists, block quote, and link are all present (`format_bold` … `format_link_*` strings). **Headings were only "consider" in the original scope and are not implemented** — see the gap below for the decision this ticket still needs. - [x] Internal rich model that serializes to HTML for the message body — done: `richtext/RichText.kt` (`RichTextContent`, every `RichStyle` variant, `RichAlignment`, `RichImage`, `RichBaseStyle`, `RichLink`) + `richtext/RichTextHtml.kt` / `RichTextHtmlParser.kt` (hand-written round-trip, no parsing library). Model-layer tests exist: `RichTextEditingTest.kt`, `RichTextHtmlTest.kt`. - [x] Preserve a smooth plaintext-only experience; keep it accessible (TalkBack, large fonts) — implemented (`RichTextContent.hasFormatting()` gates HTML emission so an unformatted message stays plaintext-only; toolbar buttons carry `contentDescription`/`onClickLabel`) but **not yet verified by any test** — see the gap below. - [x] Round the compose input/container corners — done via #23 (closed): every `OutlinedTextField` in `ComposeScreen.kt` and `RichTextBodyField` already uses a rounded `shape`. ## Remaining gaps - [ ] **Decide on headings.** Not implemented anywhere in the model or toolbar. Either add an H1/H2-style block marker (mirroring the existing bullet/numbered/quote `BlockMarker` pattern in `richtext/RichTextEditing.kt`) or explicitly drop it from scope so it stops being an ambiguous "consider" item. Low priority either way — call it out so it's a decision, not an oversight. - [ ] **UI-layer test coverage for `RichTextEditor.kt`.** Everything tested so far (`RichTextEditingTest.kt`, `RichTextHtmlTest.kt`) exercises the underlying model (`RichTextEditing`, `RichTextHtml`) — nothing tests the Compose-editor glue in `RichTextEditor.kt` itself: `applyStyle`/`applyBlock`/`applyLink`, `AnnotatedString.toRichContent()`/`RichTextContent.toAnnotatedString()`, or `FormattingToolbar`'s active/inactive toggle state. These `internal` functions operate on `TextFieldValue`/`AnnotatedString`, which are plain Kotlin types usable directly in a JVM unit test — add a `RichTextEditorTest.kt` alongside the existing two. Separately, add an androidTest (`ComposeScreenTest.kt`) that taps a toolbar button and asserts the resulting content — this is the only way to actually verify the KDoc's accessibility claim (content descriptions, toggle state) rather than assume it. ## Acceptance criteria - The user can apply formatting and it round-trips into the sent message — **the #37 half of this is confirmed**: `SmtpSenderTest.kt` (GreenMail-backed) verifies a formatted message sends as `multipart/alternative` (nested inside `multipart/mixed` when there's also an attachment), and `ComposeViewModelTest.kt` confirms `bodyHtml` stays `null` when nothing is formatted and carries the right HTML when it is. So this ticket is not blocked on #37 for closing. - Plaintext-only composing is unchanged in feel — behaviorally true by inspection (`hasFormatting()` gate) but not yet covered by a test at the editor layer; folded into the gap above. ## Relevant files - `ui/compose/ComposeScreen.kt`, `ui/compose/ComposeViewModel.kt`, `ui/compose/RichTextEditor.kt`, `richtext/RichText.kt`, `richtext/RichTextEditing.kt`, `richtext/RichTextHtml.kt`, `richtext/RichTextHtmlParser.kt`. ## Notes Pairs with #37 (send path — confirmed implemented/tested, see above); folds in #23 (done).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#36