neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Draw link badges, and collect socket events to completion
b60324d · Gabriel Morell · 2026-09-16 03:41
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