diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 6241950..b7f624a 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -336,7 +336,10 @@ fun EditorLabelRow( modifier = Modifier.padding(vertical = 2.dp), ) { Text( - text = label.name, + // `#` on every chip, matching the card. This row still shows the + // tags the BODY owns as well — it is the control surface, and the + // "from tag" hint beside one is what says why it has no cross. + text = "#${label.name}", style = MaterialTheme.typography.labelLarge, color = tint.chipForeground(dark), modifier = diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index a9db112..0f940e5 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -22,7 +22,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -30,6 +34,7 @@ import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.BodyItem import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.NoteLabel +import com.fabledsword.thoughtsync.core.bodyTags import com.fabledsword.thoughtsync.core.checklistItems @Composable @@ -67,8 +72,16 @@ fun NoteCard( // note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete // with the thing that identifies the note. A row of its own costs one line and // only on notes that have tags at all. - if (note.labels.isNotEmpty()) { - LabelChips(labels = note.labels) + // + // ONLY the labels whose text is not still in the note. `via_tag` means exactly + // "backed by body text" since M311, so a chip for one printed the same tag + // twice — once where it was typed, once up here — and the card was carrying + // furniture for information it was already showing. A tag left in prose is + // tinted in place instead; see [tintTags]. What reaches this row is what the + // body cannot say: a tag lifted off its own line, and a label added by hand. + val chips = note.labels.filterNot { it.viaTag } + if (chips.isNotEmpty()) { + LabelChips(labels = chips) Spacer(Modifier.height(8.dp)) } @@ -129,13 +142,13 @@ private fun NoteBody( val found = itemAtLine[n] when { found != null -> - ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) } + ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) } // Kept as a gap rather than dropped: it is the paragraph break // somebody typed, and the card reads as a wall without it. line.isBlank() -> Spacer(Modifier.height(4.dp)) else -> Text( - text = line, + text = tintTags(line, note), style = MaterialTheme.typography.bodyMedium, maxLines = MAX_WRAPPED_LINES, overflow = TextOverflow.Ellipsis, @@ -163,6 +176,7 @@ private fun NoteBody( */ @Composable private fun ChecklistRow( + note: Note, item: BodyItem, onToggle: () -> Unit, ) { @@ -176,7 +190,7 @@ private fun ChecklistRow( .padding(end = 6.dp), ) Text( - text = item.text, + text = tintTags(item.text, note), style = MaterialTheme.typography.bodyMedium, textDecoration = if (item.checked) TextDecoration.LineThrough else null, color = @@ -191,6 +205,58 @@ private fun ChecklistRow( } } +/** + * One string of a note's own words, with every `#tag` in it drawn in that tag's colour. + * + * This is what replaced the chip for a tag still living in the prose. The card used to + * print such a tag twice — once where it was typed and once in the row above — and the + * duplicate was the loud copy, which made a tagged note read as "tag, then some text + * that happens to start with the same word". Colouring it in place says the same thing + * with no furniture, and says it more honestly: the token you can see IS the text you + * would delete to remove the tag. + * + * WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which + * lines are checklist items. The grammar already exists three times (Rust, Python, + * TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one + * would fail silently, as the wrong characters tinted rather than an error anywhere. + * The core's offsets are UTF-16 code units for this call site specifically, which is + * the only unit `addStyle` can take. + * + * Called per rendered STRING rather than once per body so a checklist item's text can + * be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length + * nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one + * that shows up only on the one note that had a tag in a list. + */ +@Composable +private fun tintTags( + text: String, + note: Note, +): AnnotatedString { + val dark = isSystemInDarkTheme() + return remember(text, note.labels, dark) { + val spans = bodyTags(text) + if (spans.isEmpty()) { + AnnotatedString(text) + } else { + // A tag the note does not carry as a label yet — just typed, not yet + // derived — still gets a colour: `labelTint` falls back to deriving one + // from the name, which is what the chip would have shown anyway. + val picked = note.labels.associate { it.name.lowercase() to it.color } + buildAnnotatedString { + append(text) + spans.forEach { tag -> + val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty()) + addStyle( + SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium), + tag.start.toInt(), + tag.end.toInt(), + ) + } + } + } + } +} + @Composable private fun LabelChips(labels: List) { val dark = isSystemInDarkTheme() @@ -200,7 +266,11 @@ private fun LabelChips(labels: List) { labels.take(MAX_LABEL_CHIPS).forEach { label -> val tint = labelTintFor(label.name, label.color) Text( - text = label.name, + // The `#` is carried on every chip, because everything that reaches + // this row is a tag — a tag lifted off its own line, or one attached + // through the picker — and the hash is how you would type either. It + // also keeps a lifted chip reading as the `#todo` somebody wrote. + text = "#${label.name}", style = MaterialTheme.typography.labelSmall, color = tint.chipForeground(dark), maxLines = 1, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt index 82c23e4..5202f9f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt @@ -34,6 +34,20 @@ data class NoteTint( val lightChipForeground: Color, val darkChipBackground: Color, val darkChipForeground: Color, + /** + * The colour a `#tag` is drawn in when it is left in the prose — see [tagInk]. + * + * A SEPARATE value from [lightChipForeground] rather than a reuse of it, and the + * difference is not cosmetic. A chip brings its own `-100` fill, so its text only + * ever has to read against that one colour. Inline text sits on whatever the card + * happens to be, which includes the gray-tagged card at `neutral-200` — and there + * the chip's `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all + * under the 4.5 body text needs. One step deeper puts every hue between 5.63 and + * 12.01 in light and 7.20 and 10.84 in dark, across every fill in the palette and + * every generated fill, so it is one rule rather than three exceptions. + */ + val lightTagInk: Color, + val darkTagInk: Color, /** * False only for `default`, which is the ABSENCE of a colour rather than one of * them. A tint can be drawn at the chosen weight (see [chosenBackground]); @@ -81,6 +95,16 @@ data class NoteTint( fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground + /** + * The colour for a `#tag` still sitting in the note's own words. + * + * A tag whose text is in the body is no longer repeated as a chip (the card was + * printing every tag twice — once where it was typed, once at the top). It is + * tinted in place instead, which is both less furniture and a more honest card: + * the thing you see IS the thing you would delete to remove the tag. + */ + fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk + /** * A hairline edge for a chip, in its own text colour at low alpha. * @@ -128,6 +152,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF525252), darkChipBackground = Color(0x1AFFFFFF), darkChipForeground = Color(0xFFD4D4D4), + lightTagInk = Color(0xFF404040), + darkTagInk = Color(0xFFD4D4D4), tintable = false, ), "red" to @@ -141,6 +167,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFB91C1C), darkChipBackground = Color(0x80450A0A), darkChipForeground = Color(0xFFFCA5A5), + lightTagInk = Color(0xFF991B1B), + darkTagInk = Color(0xFFFCA5A5), ), "orange" to NoteTint( @@ -153,6 +181,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFC2410C), darkChipBackground = Color(0x80431407), darkChipForeground = Color(0xFFFDBA74), + lightTagInk = Color(0xFF9A3412), + darkTagInk = Color(0xFFFDBA74), ), "yellow" to NoteTint( @@ -165,6 +195,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF92400E), darkChipBackground = Color(0x80451A03), darkChipForeground = Color(0xFFFCD34D), + lightTagInk = Color(0xFF92400E), + darkTagInk = Color(0xFFFCD34D), ), "green" to NoteTint( @@ -177,6 +209,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF15803D), darkChipBackground = Color(0x80052E16), darkChipForeground = Color(0xFF86EFAC), + lightTagInk = Color(0xFF166534), + darkTagInk = Color(0xFF86EFAC), ), "teal" to NoteTint( @@ -189,6 +223,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF0F766E), darkChipBackground = Color(0x80042F2E), darkChipForeground = Color(0xFF5EEAD4), + lightTagInk = Color(0xFF115E59), + darkTagInk = Color(0xFF5EEAD4), ), "blue" to NoteTint( @@ -201,6 +237,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF1D4ED8), darkChipBackground = Color(0x80172554), darkChipForeground = Color(0xFF93C5FD), + lightTagInk = Color(0xFF1E40AF), + darkTagInk = Color(0xFF93C5FD), ), "purple" to NoteTint( @@ -213,6 +251,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF7E22CE), darkChipBackground = Color(0x803B0764), darkChipForeground = Color(0xFFD8B4FE), + lightTagInk = Color(0xFF6B21A8), + darkTagInk = Color(0xFFD8B4FE), ), "pink" to NoteTint( @@ -225,6 +265,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFFBE185D), darkChipBackground = Color(0x80500724), darkChipForeground = Color(0xFFF9A8D4), + lightTagInk = Color(0xFF9D174D), + darkTagInk = Color(0xFFF9A8D4), ), "gray" to NoteTint( @@ -237,6 +279,8 @@ val NOTE_TINTS: Map = lightChipForeground = Color(0xFF404040), darkChipBackground = Color(0xFF404040), darkChipForeground = Color(0xFFE5E5E5), + lightTagInk = Color(0xFF262626), + darkTagInk = Color(0xFFE5E5E5), ), ) @@ -321,4 +365,17 @@ private fun firstLabelColor(note: Note): String { fun labelTintFor( name: String, color: String, -): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys)) +): NoteTint = labelTint(name, color) + +/** + * [labelTintFor] with no composable context, for a caller building its value inside + * `remember` — where a `@Composable` call is not allowed. The card's inline tag + * colours are computed there, once per body rather than once per recomposition. + * + * One implementation, two entry points: the composable one delegates here rather than + * repeating the lookup, so the chip and the inline token cannot resolve differently. + */ +fun labelTint( + name: String, + color: String, +): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default") diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index e7349a2..c803cff 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore; use thoughtsync_core::sync::{client, compat, engine, push, state}; use models::{ - patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, - ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, + patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, + NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, }; uniffi::setup_scaffolding!(); @@ -530,6 +530,21 @@ pub fn checklist_items(body: String) -> Vec { .collect() } +/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so +/// a card can colour the tag where it was typed instead of printing it twice. +/// +/// The same argument as `checklist_items` above, and the same answer: the grammar for +/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a +/// fourth time in Compose would be a fourth place for a tag to change shape when it +/// syncs — and this one would fail silently, as the wrong characters tinted. +#[uniffi::export] +pub fn body_tags(body: String) -> Vec { + local::derive::extract_tag_spans(&body) + .into_iter() + .map(BodyTag::from) + .collect() +} + /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// block names, so these stay Rust-side. impl ThoughtSync { diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index 70f9e50..ff4d493 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -76,6 +76,36 @@ impl From for BodyItem { } } +/// One `#tag` and where it sits in a note's body. +/// +/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather +/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16 +/// code units precisely because Kotlin's `AnnotatedString` counts that way. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BodyTag { + pub line: u32, + pub start: u32, + pub end: u32, + pub name: String, +} + +impl From for BodyTag { + fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self { + let thoughtsync_core::local::derive::DerivedTag { + line, + start, + end, + name, + } = t; + BodyTag { + line, + start, + end, + name, + } + } +} + /// An Android build the linked server is offering, already judged to be newer. /// /// A mirror rather than a re-export of `client::ClientRelease`, for the same diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index cf36250..dcacbc5 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -62,6 +62,62 @@ pub fn extract_tags(body: &str) -> Vec { out } +/// One `#tag` and exactly where it sits, for a renderer drawing the body itself. +/// +/// The card no longer prints a chip for a tag whose text is still in the note — it +/// colours the token where it was typed instead. To do that a renderer needs the +/// SPAN, not just the name, and asking it to find the name again would be a second +/// grammar quietly disagreeing with this one about what `##a` or `#1` is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DerivedTag { + /// Which body line it sits on, like [`DerivedItem::line`]. + pub line: u32, + /// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`. + /// + /// UTF-16 rather than chars or bytes because the two languages that consume this + /// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char + /// index is right up until somebody puts an emoji before a tag, and then it lands + /// mid-token with no error anywhere. + pub start: u32, + pub end: u32, + pub name: String, +} + +/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does, +/// keeping the spans instead of throwing them away. +/// +/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text +/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags` +/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a +/// renderer that left it plain would be the only surface disagreeing. +pub fn extract_tag_spans(body: &str) -> Vec { + let mut out = Vec::new(); + for (n, line) in body.split('\n').enumerate() { + let chars: Vec = line.chars().collect(); + let spans = line_tags(&chars); + if spans.is_empty() { + continue; + } + // Prefix sums, built once per tagged line: char index -> UTF-16 offset. + let mut units: Vec = Vec::with_capacity(chars.len() + 1); + let mut total: u32 = 0; + units.push(0); + for c in &chars { + total += c.len_utf16() as u32; + units.push(total); + } + for (start, end, name) in spans { + out.push(DerivedTag { + line: n as u32, + start: units[start], + end: units[end], + name, + }); + } + } + out +} + /// Whether a line opens or closes a fenced code block. fn is_fence(line: &str) -> bool { let trimmed = line.trim_start(); @@ -438,6 +494,36 @@ mod tests { assert!(extract_tags("").is_empty()); } + // ── tag spans, for the renderer that draws them in place ───────────────── + + #[test] + fn tag_spans_carry_the_hash_and_the_line() { + let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom"); + assert_eq!(spans.len(), 3); + assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17)); + assert_eq!(spans[0].name, "grocery"); + // Not deduped: two mentions are two pieces of text to colour. + assert_eq!(spans[1].line, 1); + assert_eq!(spans[2].name, "mom"); + assert_eq!((spans[2].start, spans[2].end), (20, 24)); + } + + #[test] + fn tag_spans_are_utf16_offsets_not_char_indices() { + // The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index + // the second way, so a char index would highlight one character too early. + let spans = extract_tag_spans("🎁 #gift"); + assert_eq!(spans.len(), 1); + assert_eq!((spans[0].start, spans[0].end), (3, 8)); + } + + #[test] + fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() { + let body = "#1 nope a#b no but #Yes ##no"; + let names: Vec = extract_tag_spans(body).into_iter().map(|t| t.name).collect(); + assert_eq!(names, extract_tags(body)); + } + // ── lifting standalone tags ────────────────────────────────────────────── // // The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case. diff --git a/frontend/src/components/MarkdownInline.vue b/frontend/src/components/MarkdownInline.vue index 5001909..e5206bd 100644 --- a/frontend/src/components/MarkdownInline.vue +++ b/frontend/src/components/MarkdownInline.vue @@ -1,10 +1,16 @@ -
+
+ {{ lb.via_tag ? "#" + lb.name : lb.name }}#{{ lb.name }}
@@ -314,7 +337,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) blank and the link is never unreachable. -->
- +

- {{ lb.via_tag ? "#" + lb.name : lb.name }} + + #{{ lb.name }}