board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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<NoteLabel>) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -200,7 +266,11 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
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,
|
||||
|
||||
@@ -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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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<String, NoteTint> =
|
||||
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")
|
||||
|
||||
+17
-2
@@ -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<BodyItem> {
|
||||
.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<BodyTag> {
|
||||
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 {
|
||||
|
||||
@@ -76,6 +76,36 @@ impl From<thoughtsync_core::local::derive::DerivedItem> 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<thoughtsync_core::local::derive::DerivedTag> 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
|
||||
|
||||
Reference in New Issue
Block a user