Draw link badges, and collect socket events to completion

b60324d · Gabriel Morell · 2026-09-16 03:41

15 files +198 -3
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/diogramos/app/MainActivity.kt
+3 −0
@@ -150,6 +150,9 @@ fun DiogramosApp(tokens: TokenStore, initialDeepLink: DeepLink?) {
150 150 slug = entry.arguments?.getString("slug").orEmpty(),
151 151 tokens = tokens,
152 152 onBack = { if (!nav.popBackStack()) nav.navigate("canvases") },
153 + // A canvas-to-canvas link stays in the app, and pushes a
154 + // new entry so Back returns to the canvas you came from.
155 + onOpenCanvas = { slug -> nav.navigate("canvas/$slug") },
153 156 )
154 157 }
155 158 }
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasDocument.kt
+50 −0
@@ -103,7 +103,33 @@ data class Element(
103 103 val style: Style,
104 104 /** Circles keep their radius: drawing one as its bounding box is wrong. */
105 105 val radius: Double? = null,
106 + val links: List<Link> = emptyList(),
106 107 ) {
108 + /**
109 + * Badge rects in document space, matching `link_overlay_geometry/1`
110 + * and `link_placements/3`: one square per enabled link, stacked
111 + * leftward from the element's bottom-right corner so the first link
112 + * sits closest to it.
113 + *
114 + * Shared by the renderer and the tap handler, so what is drawn and
115 + * what is touchable cannot drift apart.
116 + */
117 + fun linkBadges(): List<Pair<Link, Rect>> {
118 + val enabled = links.filter { it.enabled }
119 + if (enabled.isEmpty()) return emptyList()
120 +
121 + val smallest = minOf(bbox.w, bbox.h)
122 + val size = maxOf(8.0, smallest * 0.08)
123 + val pad = 2.0
124 + val gap = 2.0
125 + val originX = bbox.x + bbox.w - size - pad
126 + val originY = bbox.y + bbox.h - size - pad
127 +
128 + return enabled.mapIndexed { index, link ->
129 + link to Rect(originX - index * (size + gap), originY, size, size)
130 + }
131 + }
132 +
107 133 companion object {
108 134 fun parse(id: String, json: JsonObject): Element? {
109 135 val type = ElementType.from(json["type"].asStringOrNull()) ?: return null
@@ -132,11 +158,35 @@ data class Element(
132 158 label = json["label"].asStringOrNull().orEmpty(),
133 159 style = Style.parse(json["style"] as? JsonObject),
134 160 radius = radius,
161 + links = (json["links"] as? JsonArray)
162 + ?.mapNotNull { (it as? JsonObject)?.let(Link::parse) }
163 + .orEmpty(),
135 164 )
136 165 }
137 166 }
138 167 }
139 168
169 +/**
170 + * A link badge on an element. `kind` is "external" (a URL) or "canvas"
171 + * (another canvas by slug, which the app opens in place rather than
172 + * handing to a browser).
173 + */
174 +data class Link(
175 + val enabled: Boolean,
176 + val kind: String,
177 + val target: String,
178 + val icon: String,
179 +) {
180 + companion object {
181 + fun parse(json: JsonObject): Link = Link(
182 + enabled = json["enabled"].asBooleanOrNull() ?: false,
183 + kind = json["kind"].asStringOrNull() ?: "external",
184 + target = json["target"].asStringOrNull().orEmpty(),
185 + icon = json["icon"].asStringOrNull() ?: "link",
186 + )
187 + }
188 +}
189 +
140 190 /**
141 191 * Colors are daisyUI *token names*, not hex — they resolve against the
142 192 * canvas's theme at draw time (see `AppTheme.token`). A null fill means
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasScreen.kt
+51 −3
@@ -49,7 +49,6 @@ import androidx.compose.ui.input.pointer.positionChange
49 49 import androidx.compose.ui.text.rememberTextMeasurer
50 50 import androidx.compose.ui.unit.dp
51 51 import kotlinx.coroutines.Job
52 −import kotlinx.coroutines.flow.collectLatest
53 52 import kotlinx.coroutines.launch
54 53 import kotlinx.serialization.json.JsonObject
55 54 import org.neiam.diogramos.app.data.Canvas as CanvasMeta
@@ -83,6 +82,7 @@ fun CanvasScreen(
83 82 slug: String,
84 83 tokens: TokenStore,
85 84 onBack: () -> Unit,
85 + onOpenCanvas: (String) -> Unit = {},
86 86 ) {
87 87 var meta by remember { mutableStateOf<CanvasMeta?>(null) }
88 88 var error by remember { mutableStateOf<String?>(null) }
@@ -141,7 +141,13 @@ fun CanvasScreen(
141 141 if (canvasId == null || creds == null) return@DisposableEffect onDispose { }
142 142
143 143 val job: Job = scope.launch {
144 − CanvasSocket(creds).subscribe(canvasId).collectLatest { event ->
144 + // collect, not collectLatest: every event has to run to
145 + // completion. collectLatest cancels the handler for the
146 + // previous event when the next arrives, which here can mean
147 + // an op applied to the session whose recomposition never
148 + // happens, or a resync torn off half-done -- and it is what
149 + // surfaced as "Child of the scoped flow was cancelled".
150 + CanvasSocket(creds).subscribe(canvasId).collect { event ->
145 151 when (event) {
146 152 // The first connect waits for the join and its
147 153 // snapshot. A later one is a reconnect: the client
@@ -270,7 +276,11 @@ fun CanvasScreen(
270 276
271 277 val canvasTheme = appThemeByKey(meta?.theme)
272 278 val textMeasurer = rememberTextMeasurer()
273 − val renderer = remember(canvasTheme) { Renderer(canvasTheme, textMeasurer) }
279 + val linkIcons = rememberLinkIcons()
280 + val renderer = remember(canvasTheme, linkIcons) {
281 + Renderer(canvasTheme, textMeasurer, linkIcons)
282 + }
283 + val context = androidx.compose.ui.platform.LocalContext.current
274 284
275 285 // Frame the content once, so a later op doesn't yank the view back
276 286 // from wherever it has been panned to.
@@ -385,9 +395,16 @@ fun CanvasScreen(
385 395 },
386 396 onTap = { position ->
387 397 val (x, y) = viewport.toDocument(position)
398 +
399 + // Badges sit on top of their shape,
400 + // so they get the tap first --
401 + // otherwise a link on a small shape
402 + // could never be hit.
403 + val link = currentDocument.linkAt(x, y)
388 404 val hit = currentDocument.elementAt(x, y)
389 405
390 406 when {
407 + link != null -> openLink(context, link, onOpenCanvas)
391 408 hit == null -> selectedId = null
392 409 hit.id == selectedId -> showSelectionSheet = true
393 410 else -> selectedId = hit.id
@@ -479,6 +496,37 @@ fun CanvasScreen(
479 496 }
480 497 }
481 498
499 +/**
500 + * Follows a tapped badge: a canvas link opens in the app, an external one
501 + * goes to the browser. `link_href/2` builds `/c/<slug>` for the former,
502 + * which is the web's way of saying "the canvas with this slug" — the app
503 + * navigates to it directly rather than round-tripping through a browser.
504 + */
505 +private fun openLink(
506 + context: android.content.Context,
507 + link: Link,
508 + onOpenCanvas: (String) -> Unit,
509 +) {
510 + if (link.target.isBlank()) return
511 +
512 + if (link.kind == "canvas") {
513 + onOpenCanvas(link.target)
514 + return
515 + }
516 +
517 + // An arbitrary target string out of a shared document is not
518 + // something to hand the OS unexamined: only http(s) is followed.
519 + val uri = runCatching { android.net.Uri.parse(link.target) }.getOrNull() ?: return
520 + if (uri.scheme != "http" && uri.scheme != "https") return
521 +
522 + runCatching {
523 + context.startActivity(
524 + android.content.Intent(android.content.Intent.ACTION_VIEW, uri)
525 + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
526 + )
527 + }
528 +}
529 +
482 530 /** How often an in-flight drag is published — about ten times a second. */
483 531 private const val DRAG_OP_INTERVAL_MS = 100L
484 532
added android/app/src/main/java/org/neiam/diogramos/app/canvas/LinkIcons.kt
+32 −0
@@ -0,0 +1,32 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import androidx.compose.material.icons.Icons
4 +import androidx.compose.material.icons.filled.Bookmark
5 +import androidx.compose.material.icons.filled.Description
6 +import androidx.compose.material.icons.filled.Info
7 +import androidx.compose.material.icons.filled.Link
8 +import androidx.compose.material.icons.filled.OpenInNew
9 +import androidx.compose.material.icons.filled.Public
10 +import androidx.compose.runtime.Composable
11 +import androidx.compose.ui.graphics.vector.VectorPainter
12 +import androidx.compose.ui.graphics.vector.rememberVectorPainter
13 +
14 +/**
15 + * The six icons `Document.link_icons/0` accepts, as their nearest
16 + * Material equivalents.
17 + *
18 + * The web draws heroicons from an SVG sprite. Shipping those paths to
19 + * get a glyph that reads as "there is a link here" at eight pixels would
20 + * be effort spent where nobody can see it; what matters is that the
21 + * badge appears in the same corner, at the same size, in the accent
22 + * colour, and means the same thing.
23 + */
24 +@Composable
25 +fun rememberLinkIcons(): Map<String, VectorPainter> = mapOf(
26 + "link" to rememberVectorPainter(Icons.Default.Link),
27 + "arrow-top-right-on-square" to rememberVectorPainter(Icons.Default.OpenInNew),
28 + "document-text" to rememberVectorPainter(Icons.Default.Description),
29 + "bookmark" to rememberVectorPainter(Icons.Default.Bookmark),
30 + "globe-alt" to rememberVectorPainter(Icons.Default.Public),
31 + "information-circle" to rememberVectorPainter(Icons.Default.Info),
32 +)
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/Renderer.kt
+62 −0
@@ -7,7 +7,10 @@ import androidx.compose.ui.graphics.Color
7 7 import androidx.compose.ui.graphics.Path
8 8 import androidx.compose.ui.graphics.PathEffect
9 9 import androidx.compose.ui.graphics.drawscope.DrawScope
10 +import androidx.compose.ui.graphics.ColorFilter
10 11 import androidx.compose.ui.graphics.drawscope.Stroke
12 +import androidx.compose.ui.graphics.drawscope.translate
13 +import androidx.compose.ui.graphics.vector.VectorPainter
11 14 import androidx.compose.ui.text.TextLayoutResult
12 15 import androidx.compose.ui.text.drawText
13 16 import androidx.compose.ui.text.TextMeasurer
@@ -32,6 +35,8 @@ import kotlin.math.sin
32 35 class Renderer(
33 36 private val theme: AppTheme,
34 37 private val textMeasurer: TextMeasurer,
38 + /** Icon painters by `Document.link_icons/0` name; see LinkIcons. */
39 + private val linkIcons: Map<String, VectorPainter> = emptyMap(),
35 40 ) {
36 41 /**
37 42 * [preview] overrides one element's box while it is being dragged.
@@ -163,6 +168,52 @@ class Renderer(
163 168 if (element.label.isNotEmpty()) {
164 169 drawLabel(scope, element, topLeft, size, viewport)
165 170 }
171 +
172 + drawLinkBadges(scope, element, viewport, dimmed)
173 + }
174 +
175 + /**
176 + * The little badges in an element's bottom-right corner, one per
177 + * enabled link — `link_overlay/1` on the web: a base-100 square
178 + * outlined in the accent colour with the icon inside it.
179 + */
180 + private fun drawLinkBadges(
181 + scope: DrawScope,
182 + element: Element,
183 + viewport: Viewport,
184 + dimmed: Boolean,
185 + ) {
186 + element.linkBadges().forEach { (link, rect) ->
187 + val topLeft = viewport.toScreen(rect.x, rect.y)
188 + val side = (rect.w * viewport.scale).toFloat()
189 + // Under a few pixels a badge is a speck that cannot be read
190 + // or tapped; the web has the same practical floor at zoom.
191 + if (side < 6f) return@forEach
192 +
193 + val accent = theme.accent.ghost(dimmed)
194 + val corner = androidx.compose.ui.geometry.CornerRadius(3f * viewport.scale)
195 +
196 + scope.drawRoundRect(
197 + theme.base100.ghost(dimmed),
198 + Offset(topLeft.x - viewport.scale, topLeft.y - viewport.scale),
199 + Size(side + 2 * viewport.scale, side + 2 * viewport.scale),
200 + corner,
201 + )
202 + scope.drawRoundRect(
203 + accent,
204 + Offset(topLeft.x - viewport.scale, topLeft.y - viewport.scale),
205 + Size(side + 2 * viewport.scale, side + 2 * viewport.scale),
206 + corner,
207 + style = Stroke(width = 1f * viewport.scale),
208 + )
209 +
210 + val painter = linkIcons[link.icon] ?: linkIcons["link"] ?: return@forEach
211 + scope.translate(topLeft.x, topLeft.y) {
212 + with(painter) {
213 + draw(Size(side, side), colorFilter = ColorFilter.tint(accent))
214 + }
215 + }
216 + }
166 217 }
167 218
168 219 /**
@@ -453,6 +504,17 @@ fun handleAt(bbox: Rect, viewport: Viewport, point: Offset): DragState.Kind? =
453 504 ?.takeIf { (_, center) -> (center - point).getDistance() <= HANDLE_RADIUS_PX * 1.6f }
454 505 ?.key
455 506
507 +/**
508 + * The link badge under a document-space point, if any. Searched
509 + * front-to-back like [elementAt], so the badge you can see is the one
510 + * you hit.
511 + */
512 +fun CanvasDocument.linkAt(x: Double, y: Double): Link? =
513 + paintOrder.asReversed()
514 + .firstNotNullOfOrNull { element ->
515 + element.linkBadges().firstOrNull { (_, rect) -> rect.contains(x, y) }?.first
516 + }
517 +
456 518 /** Topmost element under a document-space point, or null. */
457 519 fun CanvasDocument.elementAt(x: Double, y: Double): Element? =
458 520 paintOrder.lastOrNull { it.bbox.contains(x, y) }

Parents: 10ab3fb