Android client phase 4: editing on the phone

e61db36 · Gabriel Morell · 2026-09-14 22:48

28 files +1263 -31
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+28 −9
@@ -6,9 +6,9 @@ Native Android client for [Diogramos](../). Talks to the JSON API at
6 6 typography, so the two feel like one product family.
7 7
8 8 The design doc and phase plan live in [docs/android.md](../docs/android.md).
9 −Shipped so far: **phase 3** — auth, the canvas list, a read-only render
10 −with pan and zoom, and live updates over the Phoenix channel. Editing is
11 −phase 4.
9 +Shipped so far: **phase 4** — auth, the canvas list, a live render with
10 +pan and zoom, and light editing: move, resize, text, colours, add shape,
11 +delete, z-order.
12 12
13 13 ## Stack
14 14
@@ -161,17 +161,36 @@ is why `PairPayload` parses with `java.net.URI` instead of
161 161 silently stores the wrong server, so it is worth being able to test it
162 162 directly.
163 163
164 +## Editing
165 +
166 +Tap selects, tapping the selection opens the sheet, dragging moves, and
167 +the corner handles resize. Connectors are rendered but not authored here
168 +— picking an anchor on a phone is its own design problem, and it is not
169 +what mobile users are blocked on.
170 +
171 +Edits apply on screen before the server answers, because an edit that
172 +waits for a round trip feels broken under a finger. If the server refuses
173 +one it is rolled back — unless a later edit is already in flight, in
174 +which case undoing it would also undo what came after, and the screen
175 +resyncs instead.
176 +
177 +A drag doesn't touch the document while the finger is down: each op costs
178 +a server round trip, a broadcast to every peer and a WAL row, so the
179 +in-flight geometry is a render-time override and ops go out about ten
180 +times a second plus once at the end.
181 +
164 182 ## Not yet shipped
165 183
166 −Editing (phase 4), export and the theme picker (phase 5), and the
167 −launcher icon and release/F-Droid pipeline (phase 6). See
184 +Export and the theme picker (phase 5), and the launcher icon and
185 +release/F-Droid pipeline (phase 6). See
168 186 [docs/android.md](../docs/android.md).
169 187
170 188 Nothing has yet been seen running on a screen: the unit tests cover the
171 −geometry, op application, the parser, the viewport maths and the
172 −reconciliation rules, but no emulator or device has been available, so
173 −neither a rendered diagram nor a live connection has been confirmed
174 −against a real server.
189 +geometry, op application, the parser, the viewport maths, the
190 +reconciliation rules and the editing maths, but no emulator or device has
191 +been available. Neither a rendered diagram, a live connection nor a
192 +gesture has been confirmed against a real server — which is the main
193 +thing worth doing before trusting the write path.
175 194
176 195 Nothing has yet been seen running on a screen: the unit tests cover the
177 196 geometry, the parser and the viewport maths, but no emulator or device
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasScreen.kt
+248 −10
@@ -2,8 +2,11 @@ package org.neiam.diogramos.app.canvas
2 2
3 3 import androidx.compose.foundation.Canvas
4 4 import androidx.compose.foundation.background
5 +import androidx.compose.foundation.gestures.awaitEachGesture
6 +import androidx.compose.foundation.gestures.awaitFirstDown
5 7 import androidx.compose.foundation.gestures.detectTapGestures
6 8 import androidx.compose.foundation.gestures.detectTransformGestures
9 +import androidx.compose.foundation.gestures.drag
7 10 import androidx.compose.foundation.layout.Arrangement
8 11 import androidx.compose.foundation.layout.Box
9 12 import androidx.compose.foundation.layout.Column
@@ -13,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
13 16 import androidx.compose.foundation.layout.padding
14 17 import androidx.compose.material.icons.Icons
15 18 import androidx.compose.material.icons.automirrored.filled.ArrowBack
19 +import androidx.compose.material.icons.filled.Add
16 20 import androidx.compose.material.icons.filled.CenterFocusStrong
17 21 import androidx.compose.material3.CircularProgressIndicator
18 22 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,26 +35,34 @@ import androidx.compose.runtime.mutableIntStateOf
31 35 import androidx.compose.runtime.mutableStateOf
32 36 import androidx.compose.runtime.remember
33 37 import androidx.compose.runtime.rememberCoroutineScope
38 +import androidx.compose.runtime.rememberUpdatedState
34 39 import androidx.compose.runtime.saveable.rememberSaveable
35 40 import androidx.compose.runtime.setValue
36 41 import androidx.compose.ui.Alignment
37 42 import androidx.compose.ui.Modifier
38 43 import androidx.compose.ui.geometry.Size
39 44 import androidx.compose.ui.graphics.Color
45 +import androidx.compose.ui.geometry.Offset
46 +import androidx.compose.ui.input.pointer.PointerInputChange
40 47 import androidx.compose.ui.input.pointer.pointerInput
48 +import androidx.compose.ui.input.pointer.positionChange
41 49 import androidx.compose.ui.text.rememberTextMeasurer
42 50 import androidx.compose.ui.unit.dp
43 51 import kotlinx.coroutines.Job
44 52 import kotlinx.coroutines.flow.collectLatest
45 53 import kotlinx.coroutines.launch
54 +import kotlinx.serialization.json.JsonObject
46 55 import org.neiam.diogramos.app.data.Canvas as CanvasMeta
47 56 import org.neiam.diogramos.app.data.CanvasEvent
57 +import org.neiam.diogramos.app.data.CanvasSender
48 58 import org.neiam.diogramos.app.data.CanvasSocket
49 59 import org.neiam.diogramos.app.data.DiogramosClient
50 60 import org.neiam.diogramos.app.data.Peer
51 61 import org.neiam.diogramos.app.data.TokenStore
52 62 import org.neiam.diogramos.app.data.describeApiFailure
53 63 import org.neiam.diogramos.app.ui.AppBarTitle
64 +import androidx.compose.material3.SnackbarHost
65 +import androidx.compose.material3.SnackbarHostState
54 66 import org.neiam.diogramos.app.ui.theme.appThemeByKey
55 67
56 68 private enum class LiveState { Connecting, Live, Offline }
@@ -78,6 +90,12 @@ fun CanvasScreen(
78 90 var viewSize by remember { mutableStateOf(Size.Zero) }
79 91 var live by remember { mutableStateOf(LiveState.Connecting) }
80 92 var peers by remember { mutableStateOf<List<Peer>>(emptyList()) }
93 + var sender by remember { mutableStateOf<CanvasSender?>(null) }
94 + var selectedId by remember { mutableStateOf<String?>(null) }
95 + var drag by remember { mutableStateOf<DragState?>(null) }
96 + var showAddSheet by remember { mutableStateOf(false) }
97 + var showSelectionSheet by remember { mutableStateOf(false) }
98 + val snackbars = remember { SnackbarHostState() }
81 99
82 100 // The session holds the document; this counter is what tells Compose
83 101 // it changed, since JsonObject is not a Compose-observable type.
@@ -129,6 +147,9 @@ fun CanvasScreen(
129 147 is CanvasEvent.Disconnected -> {
130 148 live = LiveState.Offline
131 149 peers = emptyList()
150 + // Nothing can be sent while disconnected, and a
151 + // stale sender would swallow edits silently.
152 + sender = null
132 153 }
133 154
134 155 is CanvasEvent.Joined -> {
@@ -137,6 +158,8 @@ fun CanvasScreen(
137 158 session.onSnapshot(event.document, event.version)
138 159 revision++
139 160 live = LiveState.Live
161 + sender = event.sender
162 + meta = meta?.copy(role = event.role ?: meta?.role)
140 163 }
141 164
142 165 is CanvasEvent.Op -> {
@@ -168,6 +191,60 @@ fun CanvasScreen(
168 191 onDispose { job.cancel() }
169 192 }
170 193
194 + val canWrite = meta?.canWrite ?: false
195 + val selected = document.elements[selectedId]
196 +
197 + // Gesture detectors must not be keyed on `revision`: a drag sends ops
198 + // as it goes, and restarting the pointerInput on the resulting
199 + // recomposition cancels the very gesture that produced them. These
200 + // keep the handlers reading current state without being restarted.
201 + val currentDocument by rememberUpdatedState(document)
202 + val currentSelected by rememberUpdatedState(selected)
203 +
204 + /**
205 + * Applies an op locally, sends it, and undoes it if the server says
206 + * no. The optimistic apply is the point: an edit that waits for a
207 + * round trip feels broken under a finger.
208 + */
209 + fun send(op: JsonObject) {
210 + val channel = sender ?: run {
211 + scope.launch { snackbars.showMessage("Not connected — that change wasn't saved.") }
212 + return
213 + }
214 +
215 + val edit = session.localOp(op) ?: run {
216 + scope.launch { snackbars.showMessage("That change isn't possible.") }
217 + return
218 + }
219 +
220 + revision++
221 +
222 + channel.pushOp(
223 + op = op,
224 + actorRef = edit.actorRef,
225 + onAccepted = { version -> session.acknowledge(edit.actorRef, version) },
226 + onRejected = { reason ->
227 + // Roll back if this was the only edit in flight; if it
228 + // wasn't, undoing it would also undo what came after, so
229 + // the honest repair is a resync.
230 + if (!session.rollback(edit.actorRef)) {
231 + scope.launch { load() }
232 + } else {
233 + revision++
234 + }
235 +
236 + scope.launch { snackbars.showMessage(rejectionMessage(reason)) }
237 + },
238 + )
239 + }
240 +
241 + fun commitDrag(state: DragState, final: Boolean) {
242 + val element = currentDocument.elements[state.elementId] ?: return
243 + send(Ops.updateElement(element.id, Ops.geometryPatch(element, state.current)))
244 +
245 + if (final) sender?.pushCursor(null, null, null)
246 + }
247 +
171 248 val canvasTheme = appThemeByKey(meta?.theme)
172 249 val textMeasurer = rememberTextMeasurer()
173 250 val renderer = remember(canvasTheme) { Renderer(canvasTheme, textMeasurer) }
@@ -191,10 +268,21 @@ fun CanvasScreen(
191 268 },
192 269 )
193 270 },
271 + snackbarHost = { SnackbarHost(snackbars) },
194 272 floatingActionButton = {
195 − if (document.elements.isNotEmpty()) {
196 − FloatingActionButton(onClick = { viewport.fit(document.bounds, viewSize) }) {
197 − Icon(Icons.Default.CenterFocusStrong, contentDescription = "Fit to content")
273 + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
274 + if (document.elements.isNotEmpty()) {
275 + FloatingActionButton(onClick = { viewport.fit(document.bounds, viewSize) }) {
276 + Icon(Icons.Default.CenterFocusStrong, contentDescription = "Fit to content")
277 + }
278 + }
279 +
280 + // No add button for a viewer: an edit that is always
281 + // refused is worse than no button at all.
282 + if (canWrite) {
283 + FloatingActionButton(onClick = { showAddSheet = true }) {
284 + Icon(Icons.Default.Add, contentDescription = "Add a shape")
285 + }
198 286 }
199 287 }
200 288 },
@@ -216,28 +304,105 @@ fun CanvasScreen(
216 304 else -> Canvas(
217 305 modifier = Modifier
218 306 .fillMaxSize()
307 + // Ordered deliberately. The shape gestures run
308 + // first and consume only when they actually
309 + // grab something, so anything that isn't a
310 + // handle or a selected shape falls through to
311 + // pan and zoom.
312 + .pointerInput(canWrite) {
313 + if (!canWrite) return@pointerInput
314 +
315 + awaitEachGesture {
316 + val down = awaitFirstDown(requireUnconsumed = false)
317 + val element = currentSelected ?: return@awaitEachGesture
318 +
319 + val kind = handleAt(element.bbox, viewport, down.position)
320 + ?: if (containsPoint(element, viewport, down.position)) {
321 + DragState.Kind.MOVE
322 + } else {
323 + return@awaitEachGesture
324 + }
325 +
326 + down.consume()
327 + var state = DragState(element.id, kind, element.bbox, element.bbox)
328 + drag = state
329 + var total = Offset.Zero
330 + var lastSent = 0L
331 +
332 + drag(down.id) { change: PointerInputChange ->
333 + total += change.positionChange()
334 + change.consume()
335 +
336 + state = state.movedBy(
337 + (total.x / viewport.scale).toDouble(),
338 + (total.y / viewport.scale).toDouble(),
339 + )
340 + drag = state
341 +
342 + // Peers see the motion, and the
343 + // shape is ghosted on their side
344 + // rather than jumping at the end.
345 + val now = System.currentTimeMillis()
346 + if (now - lastSent >= DRAG_OP_INTERVAL_MS) {
347 + lastSent = now
348 + val (docX, docY) = viewport.toDocument(change.position)
349 + sender?.pushCursor(docX, docY, element.id)
350 + commitDrag(state, final = false)
351 + }
352 + }
353 +
354 + drag = null
355 + commitDrag(state, final = true)
356 + }
357 + }
358 + .pointerInput(Unit) {
359 + detectTapGestures(
360 + onDoubleTap = {
361 + viewport.fit(currentDocument.bounds, viewSize)
362 + },
363 + onTap = { position ->
364 + val (x, y) = viewport.toDocument(position)
365 + val hit = currentDocument.elementAt(x, y)
366 +
367 + when {
368 + hit == null -> selectedId = null
369 + hit.id == selectedId -> showSelectionSheet = true
370 + else -> selectedId = hit.id
371 + }
372 + },
373 + )
374 + }
219 375 .pointerInput(Unit) {
220 376 detectTransformGestures { centroid, pan, zoom, _ ->
221 377 viewport.pan(pan.x, pan.y)
222 378 if (zoom != 1f) viewport.zoomAround(centroid, zoom)
223 379 }
224 − }
225 − .pointerInput(revision) {
226 − detectTapGestures(
227 − onDoubleTap = { viewport.fit(document.bounds, viewSize) },
228 − )
229 380 },
230 381 ) {
231 382 viewSize = size
232 383 drawRect(canvasTheme.base100, size = size)
233 − renderer.draw(this, document, viewport)
384 + renderer.draw(
385 + scope = this,
386 + document = document,
387 + viewport = viewport,
388 + preview = drag,
389 + selectedId = selectedId,
390 + // A shape a peer is dragging is ghosted, so a
391 + // position that is about to change doesn't
392 + // read as the truth.
393 + ghosted = peers.mapNotNull { it.lock }.toSet(),
394 + )
234 395 drawPeerCursors(peers, viewport)
235 396 }
236 397 }
237 398
238 399 if (!loading && error == null && document.elements.isEmpty()) {
239 400 Text(
240 − "This canvas is empty.",
401 + if (canWrite) {
402 + "This canvas is empty. Use + to add a shape."
403 + } else {
404 + "This canvas is empty."
405 + },
241 406 modifier = Modifier.align(Alignment.Center),
242 407 style = MaterialTheme.typography.bodyMedium,
243 408 color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -246,6 +411,79 @@ fun CanvasScreen(
246 411 }
247 412 }
248 413 }
414 +
415 + if (showAddSheet) {
416 + AddShapeSheet(
417 + onAdd = { type ->
418 + showAddSheet = false
419 +
420 + // Drop it in the middle of what is on screen, which is
421 + // the only place the user is definitely looking.
422 + val (x, y) = viewport.toDocument(
423 + Offset(viewSize.width / 2, viewSize.height / 2),
424 + )
425 + val element = Ops.newElement(type, x, y)
426 + send(Ops.insertElement(element))
427 + selectedId = element["id"]?.let { id ->
428 + (id as? kotlinx.serialization.json.JsonPrimitive)?.content
429 + }
430 + },
431 + onDismiss = { showAddSheet = false },
432 + )
433 + }
434 +
435 + if (showSelectionSheet && selected != null) {
436 + SelectionSheet(
437 + element = selected,
438 + canvasTheme = canvasTheme,
439 + canWrite = canWrite,
440 + onLabelChange = { send(Ops.updateElement(selected.id, Ops.labelPatch(it))) },
441 + onStyleChange = { key, value ->
442 + send(Ops.updateElement(selected.id, Ops.stylePatch(key, value)))
443 + },
444 + onReorder = { toFront ->
445 + Ops.reorder(document.order, selected.id, toFront)?.let {
446 + send(Ops.setZOrder(it))
447 + }
448 + },
449 + onDelete = {
450 + showSelectionSheet = false
451 + selectedId = null
452 + send(Ops.deleteElement(selected.id))
453 + },
454 + onDismiss = { showSelectionSheet = false },
455 + )
456 + }
457 +}
458 +
459 +/** How often an in-flight drag is published — about ten times a second. */
460 +private const val DRAG_OP_INTERVAL_MS = 100L
461 +
462 +private fun containsPoint(element: Element, viewport: Viewport, point: Offset): Boolean {
463 + val (x, y) = viewport.toDocument(point)
464 + return element.bbox.contains(x, y)
465 +}
466 +
467 +/**
468 + * Turns a rejection atom into something a person can act on. The reasons
469 + * come from `Document`, and most of them mean the client is out of step
470 + * rather than that the user did something wrong.
471 + */
472 +private fun rejectionMessage(reason: String): String = when (reason) {
473 + "forbidden" -> "You have read-only access to this canvas."
474 + "not_found" -> "That shape is already gone."
475 + "duplicate_id" -> "That shape already exists."
476 + "order_mismatch" -> "The canvas changed — try that again."
477 + "invalid_color" -> "That colour isn't allowed here."
478 + "timeout" -> "The server didn't answer — that change wasn't saved."
479 + else -> "Change refused: ${reason.replace('_', ' ')}."
480 +}
481 +
482 +private suspend fun SnackbarHostState.showMessage(message: String) {
483 + // Replaces whatever is showing: during a drag these arrive in bursts,
484 + // and a queue of identical complaints is worse than the newest one.
485 + currentSnackbarData?.dismiss()
486 + showSnackbar(message)
249 487 }
250 488
251 489 @Composable
added android/app/src/main/java/org/neiam/diogramos/app/canvas/DragState.kt
+72 −0
@@ -0,0 +1,72 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +/**
4 + * An in-flight move or resize.
5 + *
6 + * Kept out of the document: a drag would otherwise write an op per frame
7 + * into the session, and every one of those is a `GenServer.call`, a
8 + * PubSub broadcast to every peer and a WAL row on the server. The shape
9 + * is drawn from [current] while the finger is down, and ops go out on a
10 + * throttle plus once at the end.
11 + */
12 +data class DragState(
13 + val elementId: String,
14 + val kind: Kind,
15 + val origin: Rect,
16 + val current: Rect,
17 +) {
18 + enum class Kind { MOVE, RESIZE_NW, RESIZE_NE, RESIZE_SW, RESIZE_SE }
19 +
20 + fun movedBy(dx: Double, dy: Double): DragState = when (kind) {
21 + Kind.MOVE -> copy(current = origin.translate(dx, dy))
22 + else -> copy(current = origin.resized(kind, dx, dy))
23 + }
24 +
25 + companion object {
26 + /** Below this a shape is impossible to grab again after a drag. */
27 + const val MIN_SIZE = 8.0
28 + }
29 +}
30 +
31 +private fun Rect.translate(dx: Double, dy: Double) = Rect(x + dx, y + dy, w, h)
32 +
33 +private fun Rect.resized(kind: DragState.Kind, dx: Double, dy: Double): Rect {
34 + var left = x
35 + var top = y
36 + var right = x + w
37 + var bottom = y + h
38 +
39 + when (kind) {
40 + DragState.Kind.RESIZE_NW -> {
41 + left += dx
42 + top += dy
43 + }
44 +
45 + DragState.Kind.RESIZE_NE -> {
46 + right += dx
47 + top += dy
48 + }
49 +
50 + DragState.Kind.RESIZE_SW -> {
51 + left += dx
52 + bottom += dy
53 + }
54 +
55 + DragState.Kind.RESIZE_SE -> {
56 + right += dx
57 + bottom += dy
58 + }
59 +
60 + DragState.Kind.MOVE -> Unit
61 + }
62 +
63 + // Dragging a handle past the opposite edge flips the box rather than
64 + // inverting it: a negative width is rejected by the server, so the
65 + // alternative is a drag that silently stops working.
66 + val normalizedLeft = minOf(left, right)
67 + val normalizedTop = minOf(top, bottom)
68 + val width = maxOf(kotlin.math.abs(right - left), DragState.MIN_SIZE)
69 + val height = maxOf(kotlin.math.abs(bottom - top), DragState.MIN_SIZE)
70 +
71 + return Rect(normalizedLeft, normalizedTop, width, height)
72 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/EditSheets.kt
+238 −0
@@ -0,0 +1,238 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.border
5 +import androidx.compose.foundation.clickable
6 +import androidx.compose.foundation.layout.Arrangement
7 +import androidx.compose.foundation.layout.Column
8 +import androidx.compose.foundation.layout.FlowRow
9 +import androidx.compose.foundation.layout.Row
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.padding
12 +import androidx.compose.foundation.layout.size
13 +import androidx.compose.foundation.shape.CircleShape
14 +import androidx.compose.material.icons.Icons
15 +import androidx.compose.material.icons.filled.Delete
16 +import androidx.compose.material3.Button
17 +import androidx.compose.material3.ExperimentalMaterial3Api
18 +import androidx.compose.material3.Icon
19 +import androidx.compose.material3.MaterialTheme
20 +import androidx.compose.material3.ModalBottomSheet
21 +import androidx.compose.material3.OutlinedButton
22 +import androidx.compose.material3.OutlinedTextField
23 +import androidx.compose.material3.Text
24 +import androidx.compose.material3.TextButton
25 +import androidx.compose.runtime.Composable
26 +import androidx.compose.runtime.getValue
27 +import androidx.compose.runtime.mutableStateOf
28 +import androidx.compose.runtime.remember
29 +import androidx.compose.runtime.setValue
30 +import androidx.compose.ui.Alignment
31 +import androidx.compose.ui.Modifier
32 +import androidx.compose.ui.graphics.Color
33 +import androidx.compose.ui.unit.dp
34 +import org.neiam.diogramos.app.ui.theme.AppTheme
35 +
36 +/** The tokens a canvas may reference — `Themes.color_tokens/0`. */
37 +val COLOR_TOKENS = listOf(
38 + "transparent",
39 + "base-100", "base-200", "base-300", "base-content",
40 + "primary", "secondary", "accent",
41 + "info", "success", "warning", "error",
42 +)
43 +
44 +/**
45 + * Everything that can be done to the selected shape.
46 + *
47 + * A sheet rather than a side panel: at phone width a panel leaves no
48 + * canvas, and the shape being edited is the thing you need to see.
49 + */
50 +@OptIn(ExperimentalMaterial3Api::class)
51 +@Composable
52 +fun SelectionSheet(
53 + element: Element,
54 + canvasTheme: AppTheme,
55 + canWrite: Boolean,
56 + onLabelChange: (String) -> Unit,
57 + onStyleChange: (String, String?) -> Unit,
58 + onReorder: (Boolean) -> Unit,
59 + onDelete: () -> Unit,
60 + onDismiss: () -> Unit,
61 +) {
62 + var label by remember(element.id, element.label) { mutableStateOf(element.label) }
63 +
64 + ModalBottomSheet(onDismissRequest = onDismiss) {
65 + Column(
66 + modifier = Modifier.fillMaxWidth().padding(16.dp),
67 + verticalArrangement = Arrangement.spacedBy(12.dp),
68 + ) {
69 + Text(
70 + when (element.type) {
71 + ElementType.TEXT -> "Text"
72 + ElementType.CIRCLE -> "Circle"
73 + ElementType.ROUNDED -> "Rounded box"
74 + ElementType.RECT -> "Box"
75 + },
76 + style = MaterialTheme.typography.titleMedium,
77 + )
78 +
79 + if (!canWrite) {
80 + Text(
81 + "You have read-only access to this canvas.",
82 + style = MaterialTheme.typography.bodySmall,
83 + color = MaterialTheme.colorScheme.onSurfaceVariant,
84 + )
85 + return@Column
86 + }
87 +
88 + OutlinedTextField(
89 + value = label,
90 + onValueChange = { label = it },
91 + label = { Text(if (element.type == ElementType.TEXT) "Text" else "Label") },
92 + modifier = Modifier.fillMaxWidth(),
93 + )
94 +
95 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
96 + Button(
97 + onClick = { onLabelChange(label) },
98 + enabled = label != element.label,
99 + ) {
100 + Text("Save text")
101 + }
102 +
103 + OutlinedButton(onClick = { label = element.label }, enabled = label != element.label) {
104 + Text("Revert")
105 + }
106 + }
107 +
108 + // A text element paints its body with `fill`; every other type
109 + // uses fill and stroke for the shape and label_color for its
110 + // caption, so the pickers on offer differ by type.
111 + if (element.type == ElementType.TEXT) {
112 + ColorRow("Text color", element.style.fill, canvasTheme) {
113 + onStyleChange("fill", it)
114 + }
115 + } else {
116 + ColorRow("Fill", element.style.fill, canvasTheme) { onStyleChange("fill", it) }
117 + ColorRow("Stroke", element.style.stroke, canvasTheme) {
118 + onStyleChange("stroke", it)
119 + }
120 + ColorRow("Label color", element.style.labelColor, canvasTheme) {
121 + onStyleChange("label_color", it)
122 + }
123 + }
124 +
125 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
126 + OutlinedButton(onClick = { onReorder(true) }) { Text("Bring to front") }
127 + OutlinedButton(onClick = { onReorder(false) }) { Text("Send to back") }
128 + }
129 +
130 + TextButton(onClick = onDelete) {
131 + Icon(Icons.Default.Delete, contentDescription = null)
132 + Text(" Delete shape")
133 + }
134 +
135 + Text(
136 + "Drag the shape to move it, or a corner handle to resize. " +
137 + "Deleting also removes any connectors attached to it.",
138 + style = MaterialTheme.typography.bodySmall,
139 + color = MaterialTheme.colorScheme.onSurfaceVariant,
140 + )
141 + }
142 + }
143 +}
144 +
145 +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
146 +@Composable
147 +private fun ColorRow(
148 + title: String,
149 + selected: String?,
150 + canvasTheme: AppTheme,
151 + onPick: (String?) -> Unit,
152 +) {
153 + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
154 + Text(title, style = MaterialTheme.typography.labelMedium)
155 +
156 + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
157 + COLOR_TOKENS.forEach { token ->
158 + val isSelected = token == selected
159 + val swatch = canvasTheme.token(token)
160 +
161 + Box(
162 + token = token,
163 + color = swatch,
164 + selected = isSelected,
165 + outline = MaterialTheme.colorScheme.onSurface,
166 + // Tapping the current color clears it: a shape with no
167 + // fill is a legitimate state the web can produce, and
168 + // otherwise there is no way back to it here.
169 + onClick = { onPick(if (isSelected) null else token) },
170 + )
171 + }
172 + }
173 + }
174 +}
175 +
176 +@Composable
177 +private fun Box(
178 + token: String,
179 + color: Color,
180 + selected: Boolean,
181 + outline: Color,
182 + onClick: () -> Unit,
183 +) {
184 + androidx.compose.foundation.layout.Box(
185 + modifier = Modifier
186 + .size(30.dp)
187 + .background(color, CircleShape)
188 + .border(
189 + width = if (selected) 3.dp else 1.dp,
190 + color = outline.copy(alpha = if (selected) 1f else 0.25f),
191 + shape = CircleShape,
192 + )
193 + .clickable(onClick = onClick),
194 + contentAlignment = Alignment.Center,
195 + ) {
196 + if (token == "transparent") {
197 + Text("∅", style = MaterialTheme.typography.labelSmall, color = outline)
198 + }
199 + }
200 +}
201 +
202 +/** Picks what to add; the shape lands where the sheet was opened from. */
203 +@OptIn(ExperimentalMaterial3Api::class)
204 +@Composable
205 +fun AddShapeSheet(
206 + onAdd: (ElementType) -> Unit,
207 + onDismiss: () -> Unit,
208 +) {
209 + ModalBottomSheet(onDismissRequest = onDismiss) {
210 + Column(
211 + modifier = Modifier.fillMaxWidth().padding(16.dp),
212 + verticalArrangement = Arrangement.spacedBy(8.dp),
213 + ) {
214 + Text("Add a shape", style = MaterialTheme.typography.titleMedium)
215 +
216 + listOf(
217 + ElementType.RECT to "Box",
218 + ElementType.ROUNDED to "Rounded box",
219 + ElementType.CIRCLE to "Circle",
220 + ElementType.TEXT to "Text",
221 + ).forEach { (type, label) ->
222 + OutlinedButton(
223 + onClick = { onAdd(type) },
224 + modifier = Modifier.fillMaxWidth(),
225 + ) {
226 + Text(label)
227 + }
228 + }
229 +
230 + Text(
231 + "Connectors are drawn in the web editor — this app renders them but " +
232 + "doesn't create them yet.",
233 + style = MaterialTheme.typography.bodySmall,
234 + color = MaterialTheme.colorScheme.onSurfaceVariant,
235 + )
236 + }
237 + }
238 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/Ops.kt
+136 −0
@@ -0,0 +1,136 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import kotlinx.serialization.json.JsonObject
4 +import kotlinx.serialization.json.JsonPrimitive
5 +import kotlinx.serialization.json.buildJsonArray
6 +import kotlinx.serialization.json.buildJsonObject
7 +
8 +/**
9 + * Builders for the ops this client sends.
10 + *
11 + * Only the seven shapes `Document.apply_op/2` accepts exist, and v1 sends
12 + * five of them — connectors are rendered but not authored here (see
13 + * docs/android.md §1).
14 + *
15 + * Geometry patches are partial on purpose: the server deep-merges, so
16 + * sending only what moved keeps a concurrent edit to another field from
17 + * being clobbered by a stale copy of it.
18 + */
19 +object Ops {
20 +
21 + fun insertElement(element: JsonObject): JsonObject = buildJsonObject {
22 + put("type", JsonPrimitive("insert_element"))
23 + put("element", element)
24 + }
25 +
26 + fun updateElement(id: String, patch: JsonObject): JsonObject = buildJsonObject {
27 + put("type", JsonPrimitive("update_element"))
28 + put("id", JsonPrimitive(id))
29 + put("patch", patch)
30 + }
31 +
32 + fun deleteElement(id: String): JsonObject = buildJsonObject {
33 + put("type", JsonPrimitive("delete_element"))
34 + put("id", JsonPrimitive(id))
35 + }
36 +
37 + fun setZOrder(order: List<String>): JsonObject = buildJsonObject {
38 + put("type", JsonPrimitive("set_z_order"))
39 + put("order", buildJsonArray { order.forEach { add(JsonPrimitive(it)) } })
40 + }
41 +
42 + /** A geometry patch in whichever shape this element type uses. */
43 + fun geometryPatch(element: Element, bbox: Rect): JsonObject = buildJsonObject {
44 + if (element.type == ElementType.CIRCLE) {
45 + put("cx", number(bbox.centerX))
46 + put("cy", number(bbox.centerY))
47 + // A circle has one radius, so a non-square drag takes the
48 + // smaller side rather than silently turning it into an
49 + // ellipse the server has no way to store.
50 + put("r", number(minOf(bbox.w, bbox.h) / 2))
51 + } else {
52 + put("x", number(bbox.x))
53 + put("y", number(bbox.y))
54 + put("w", number(bbox.w))
55 + put("h", number(bbox.h))
56 + }
57 + }
58 +
59 + fun labelPatch(label: String): JsonObject = buildJsonObject {
60 + put("label", JsonPrimitive(label))
61 + }
62 +
63 + fun stylePatch(key: String, value: String?): JsonObject = buildJsonObject {
64 + put(
65 + "style",
66 + buildJsonObject {
67 + put(key, if (value == null) kotlinx.serialization.json.JsonNull else JsonPrimitive(value))
68 + },
69 + )
70 + }
71 +
72 + /**
73 + * A new element of [type] centred on the given document point.
74 + *
75 + * `text` gets a starting label because an empty text box is invisible
76 + * — the user would tap "Add text" and see nothing happen.
77 + */
78 + fun newElement(
79 + type: ElementType,
80 + centerX: Double,
81 + centerY: Double,
82 + id: String = Ulid.generate(),
83 + ): JsonObject = buildJsonObject {
84 + put("id", JsonPrimitive(id))
85 + put("type", JsonPrimitive(type.wire))
86 +
87 + if (type == ElementType.CIRCLE) {
88 + put("cx", number(centerX))
89 + put("cy", number(centerY))
90 + put("r", number(DEFAULT_CIRCLE_RADIUS))
91 + } else {
92 + put("x", number(centerX - DEFAULT_WIDTH / 2))
93 + put("y", number(centerY - DEFAULT_HEIGHT / 2))
94 + put("w", number(DEFAULT_WIDTH))
95 + put("h", number(DEFAULT_HEIGHT))
96 + }
97 +
98 + put("label", JsonPrimitive(if (type == ElementType.TEXT) "Text" else ""))
99 + put(
100 + "style",
101 + buildJsonObject {
102 + if (type == ElementType.TEXT) {
103 + put("fill", JsonPrimitive("base-content"))
104 + } else {
105 + put("fill", JsonPrimitive("base-200"))
106 + put("stroke", JsonPrimitive("base-content"))
107 + }
108 + },
109 + )
110 + }
111 +
112 + /** Moves [id] to the end of the paint order (front) or the start (back). */
113 + fun reorder(order: List<String>, id: String, toFront: Boolean): List<String>? {
114 + if (id !in order) return null
115 + val without = order.filterNot { it == id }
116 + val moved = if (toFront) without + id else listOf(id) + without
117 + return if (moved == order) null else moved
118 + }
119 +
120 + // Coordinates are rounded to two decimals before they are sent. The
121 + // server stores whatever it is given, and a drag otherwise writes
122 + // pixel positions with fifteen decimal places into the document --
123 + // every one of which is persisted, replicated and re-sent.
124 + private fun number(value: Double): JsonPrimitive {
125 + val rounded = Math.round(value * 100.0) / 100.0
126 + return if (rounded == Math.floor(rounded)) {
127 + JsonPrimitive(rounded.toLong())
128 + } else {
129 + JsonPrimitive(rounded)
130 + }
131 + }
132 +
133 + const val DEFAULT_WIDTH = 120.0
134 + const val DEFAULT_HEIGHT = 70.0
135 + const val DEFAULT_CIRCLE_RADIUS = 45.0
136 +}
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/Renderer.kt
+97 −7
@@ -35,29 +35,84 @@ class Renderer(
35 35 private val theme: AppTheme,
36 36 private val textMeasurer: TextMeasurer,
37 37 ) {
38 − fun draw(scope: DrawScope, document: CanvasDocument, viewport: Viewport) {
38 + /**
39 + * [preview] overrides one element's box while it is being dragged.
40 + * The drag is not written into the document until it settles — an op
41 + * per frame would be a GenServer call, a broadcast to every peer and
42 + * a WAL row each time — so the in-flight geometry is passed in here
43 + * instead, and connectors re-route against it so they follow the
44 + * shape rather than snapping at the end.
45 + */
46 + fun draw(
47 + scope: DrawScope,
48 + document: CanvasDocument,
49 + viewport: Viewport,
50 + preview: DragState? = null,
51 + selectedId: String? = null,
52 + ghosted: Set<String> = emptySet(),
53 + ) {
54 + val effective = preview?.let { document.withGeometry(it.elementId, it.current) } ?: document
55 +
39 56 // Elements first, back to front, then connectors on top: a
40 57 // connector that ducks under a shape reads as a rendering bug.
41 − document.paintOrder.forEach { drawElement(scope, it, viewport) }
58 + effective.paintOrder.forEach {
59 + drawElement(scope, it, viewport, dimmed = it.id in ghosted)
60 + }
42 61
43 − document.connectors.values.forEach { connector ->
44 − ConnectorGeometry.render(connector, document)?.let {
62 + effective.connectors.values.forEach { connector ->
63 + ConnectorGeometry.render(connector, effective)?.let {
45 64 drawConnector(scope, connector, it, viewport)
46 65 }
47 66 }
67 +
68 + selectedId?.let { id ->
69 + effective.elements[id]?.let { drawSelection(scope, it, viewport) }
70 + }
71 + }
72 +
73 + /** Corner handles, sized in screen pixels so they stay grabbable at any zoom. */
74 + private fun drawSelection(scope: DrawScope, element: Element, viewport: Viewport) {
75 + val topLeft = viewport.toScreen(element.bbox.x, element.bbox.y)
76 + val size = Size(
77 + (element.bbox.w * viewport.scale).toFloat(),
78 + (element.bbox.h * viewport.scale).toFloat(),
79 + )
80 +
81 + scope.drawRect(
82 + theme.primary,
83 + topLeft,
84 + size,
85 + style = Stroke(
86 + width = 2f,
87 + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)),
88 + ),
89 + )
90 +
91 + handleCenters(element.bbox, viewport).values.forEach { center ->
92 + scope.drawCircle(theme.primary, HANDLE_RADIUS_PX, center)
93 + scope.drawCircle(theme.base100, HANDLE_RADIUS_PX - 2f, center)
94 + }
48 95 }
49 96
50 97 // --- Elements ----------------------------------------------------------
51 98
52 − private fun drawElement(scope: DrawScope, element: Element, viewport: Viewport) {
99 + private fun drawElement(
100 + scope: DrawScope,
101 + element: Element,
102 + viewport: Viewport,
103 + dimmed: Boolean = false,
104 + ) {
53 105 val topLeft = viewport.toScreen(element.bbox.x, element.bbox.y)
54 106 val size = Size(
55 107 (element.bbox.w * viewport.scale).toFloat(),
56 108 (element.bbox.h * viewport.scale).toFloat(),
57 109 )
58 110
59 − val fill = element.style.fill?.let { theme.token(it) }
60 − val stroke = element.style.stroke?.let { theme.token(it) }
111 + // A shape someone else is dragging is ghosted rather than drawn
112 + // at a stale position, the same way the web editor does it.
113 + val alpha = if (dimmed) 0.35f else 1f
114 + val fill = element.style.fill?.let { theme.token(it).copy(alpha = alpha) }
115 + val stroke = element.style.stroke?.let { theme.token(it).copy(alpha = alpha) }
61 116 val strokeWidth = (element.style.strokeWidth * viewport.scale).toFloat()
62 117 val dash = dashEffect(element.style.dash, viewport.scale)
63 118
@@ -329,6 +384,41 @@ class Renderer(
329 384 }
330 385 }
331 386
387 +/** Screen-space radius of a corner handle: a finger, not a pixel. */
388 +const val HANDLE_RADIUS_PX = 14f
389 +
390 +fun handleCenters(bbox: Rect, viewport: Viewport): Map<DragState.Kind, Offset> = mapOf(
391 + DragState.Kind.RESIZE_NW to viewport.toScreen(bbox.x, bbox.y),
392 + DragState.Kind.RESIZE_NE to viewport.toScreen(bbox.x + bbox.w, bbox.y),
393 + DragState.Kind.RESIZE_SW to viewport.toScreen(bbox.x, bbox.y + bbox.h),
394 + DragState.Kind.RESIZE_SE to viewport.toScreen(bbox.x + bbox.w, bbox.y + bbox.h),
395 +)
396 +
397 +/** Which handle, if any, is under [point]. */
398 +fun handleAt(bbox: Rect, viewport: Viewport, point: Offset): DragState.Kind? =
399 + handleCenters(bbox, viewport)
400 + .minByOrNull { (_, center) -> (center - point).getDistance() }
401 + ?.takeIf { (_, center) -> (center - point).getDistance() <= HANDLE_RADIUS_PX * 1.6f }
402 + ?.key
403 +
404 +/** Topmost element under a document-space point, or null. */
405 +fun CanvasDocument.elementAt(x: Double, y: Double): Element? =
406 + paintOrder.lastOrNull { it.bbox.contains(x, y) }
407 +
408 +/**
409 + * The document with one element's geometry replaced — used for drag
410 + * previews, so connectors re-route against the in-flight position
411 + * without the drag being written into the document.
412 + */
413 +fun CanvasDocument.withGeometry(id: String, bbox: Rect): CanvasDocument {
414 + val element = elements[id] ?: return this
415 + val moved = element.copy(
416 + bbox = bbox,
417 + radius = element.radius?.let { minOf(bbox.w, bbox.h) / 2 },
418 + )
419 + return copy(elements = elements + (id to moved))
420 +}
421 +
332 422 /** The document-space rect currently visible, for culling. */
333 423 fun Viewport.visibleRect(size: Size): ComposeRect {
334 424 val (left, top) = toDocument(Offset.Zero)
added android/app/src/main/java/org/neiam/diogramos/app/canvas/Ulid.kt
+54 −0
@@ -0,0 +1,54 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import java.math.BigInteger
4 +import java.security.SecureRandom
5 +
6 +/**
7 + * Element ids, in the same format the server and the web editor use:
8 + * a 26-character Crockford base32 ULID — 48 bits of millisecond
9 + * timestamp followed by 80 bits of randomness.
10 + *
11 + * The id is minted on the device because an `insert_element` op carries
12 + * it; the server takes the id as given. Matching `Diogramos.ULID` matters
13 + * for the same reason the format was chosen there: ids sort
14 + * chronologically, so a document's elements stay in creation order
15 + * wherever they were made.
16 + */
17 +object Ulid {
18 + private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
19 + private val random = SecureRandom()
20 +
21 + fun generate(timestamp: Long = System.currentTimeMillis()): String {
22 + val bytes = ByteArray(16)
23 +
24 + // 48-bit big-endian timestamp.
25 + for (i in 0 until 6) {
26 + bytes[i] = ((timestamp shr ((5 - i) * 8)) and 0xFF).toByte()
27 + }
28 +
29 + val entropy = ByteArray(10)
30 + random.nextBytes(entropy)
31 + entropy.copyInto(bytes, destinationOffset = 6)
32 +
33 + return encode(bytes)
34 + }
35 +
36 + /**
37 + * Base-32 of the whole 128-bit value, most significant digit first —
38 + * `Diogramos.ULID.do_encode/3` exactly, which is 26 digits for any
39 + * 128-bit input.
40 + */
41 + private fun encode(bytes: ByteArray): String {
42 + var value = BigInteger(1, bytes)
43 + val chars = CharArray(26)
44 +
45 + for (i in 25 downTo 0) {
46 + chars[i] = ALPHABET[value.mod(THIRTY_TWO).toInt()]
47 + value = value.divide(THIRTY_TWO)
48 + }
49 +
50 + return String(chars)
51 + }
52 +
53 + private val THIRTY_TWO = BigInteger.valueOf(32)
54 +}
modified android/app/src/main/java/org/neiam/diogramos/app/data/CanvasSocket.kt
+69 −2
@@ -78,6 +78,7 @@ class CanvasSocket(private val credentials: TokenStore.Credentials) {
78 78 document = document,
79 79 version = version,
80 80 role = payload["role"]?.stringOrNull(),
81 + sender = CanvasSender(channel),
81 82 ),
82 83 )
83 84 }
@@ -120,6 +121,50 @@ class CanvasSocket(private val credentials: TokenStore.Credentials) {
120 121 }
121 122 }
122 123
124 +/**
125 + * The write half of the channel, handed out once the join succeeds —
126 + * there is nowhere to send an op before that anyway.
127 + *
128 + * Every op is acknowledged: the reply carries the version the server
129 + * gave it, or the reason it was refused. A caller that ignores the reply
130 + * leaves an optimistic edit on screen that never happened.
131 + */
132 +class CanvasSender(private val channel: Channel) {
133 +
134 + fun pushOp(
135 + op: JsonObject,
136 + actorRef: String,
137 + onAccepted: (Long) -> Unit,
138 + onRejected: (String) -> Unit,
139 + ) {
140 + channel.push("op", mapOf("op" to op.toAnyMap(), "actor_ref" to actorRef))
141 + .receive("ok") { message ->
142 + val version = message.payload.toJsonObject()["version"]?.longOrNull()
143 + if (version == null) onRejected("bad_reply") else onAccepted(version)
144 + }
145 + .receive("error") { message ->
146 + onRejected(message.payload.toJsonObject()["code"]?.stringOrNull() ?: "rejected")
147 + }
148 + .receive("timeout") { onRejected("timeout") }
149 + }
150 +
151 + /**
152 + * Publishes this device's pointer while a drag is in flight. Fire and
153 + * forget: a dropped cursor frame costs a peer one stale ghost, and
154 + * retrying it would only deliver a position that is already wrong.
155 + */
156 + fun pushCursor(x: Double?, y: Double?, lock: String?) {
157 + val payload = mutableMapOf<String, Any?>()
158 + if (x != null && y != null) {
159 + payload["x"] = x
160 + payload["y"] = y
161 + }
162 + if (lock != null) payload["lock"] = lock
163 +
164 + channel.push("cursor", payload)
165 + }
166 +}
167 +
123 168 /** Another person on the same canvas. */
124 169 data class Peer(
125 170 val key: String,
@@ -138,6 +183,7 @@ sealed interface CanvasEvent {
138 183 val document: JsonObject,
139 184 val version: Long,
140 185 val role: String?,
186 + val sender: CanvasSender,
141 187 ) : CanvasEvent
142 188
143 189 data class Op(
@@ -151,8 +197,29 @@ sealed interface CanvasEvent {
151 197 data class Failed(val message: String) : CanvasEvent
152 198 }
153 199
154 −// The Phoenix client hands payloads back as nested Maps and Lists, so
155 −// they are converted once here rather than at every use site.
200 +// The Phoenix client speaks in nested Maps and Lists in both directions,
201 +// so the conversion lives here rather than at every use site.
202 +private fun JsonObject.toAnyMap(): Map<String, Any?> =
203 + mapValues { (_, value) -> value.toAny() }
204 +
205 +private fun kotlinx.serialization.json.JsonElement.toAny(): Any? = when (this) {
206 + is JsonObject -> toAnyMap()
207 + is kotlinx.serialization.json.JsonArray -> map { it.toAny() }
208 + is JsonPrimitive -> when {
209 + this is kotlinx.serialization.json.JsonNull -> null
210 + isString -> content
211 + content == "true" -> true
212 + content == "false" -> false
213 + // Integers must not be widened: the server stores what it is
214 + // given, and a coordinate written as 40.0 where the web writes 40
215 + // shows up as a diff in every later comparison.
216 + content.toLongOrNull() != null -> content.toLong()
217 + else -> content.toDoubleOrNull() ?: content
218 + }
219 +
220 + else -> null
221 +}
222 +
156 223 private fun Map<String, Any?>.toJsonObject(): JsonObject = buildJsonObject {
157 224 this@toJsonObject.forEach { (key, value) -> put(key, value.toJsonElement()) }
158 225 }
added android/app/src/test/java/org/neiam/diogramos/app/EditingTest.kt
+296 −0
@@ -0,0 +1,296 @@
1 +package org.neiam.diogramos.app
2 +
3 +import kotlinx.serialization.json.Json
4 +import kotlinx.serialization.json.JsonObject
5 +import kotlinx.serialization.json.jsonArray
6 +import kotlinx.serialization.json.jsonObject
7 +import kotlinx.serialization.json.jsonPrimitive
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Assert.assertNotNull
10 +import org.junit.Assert.assertNull
11 +import org.junit.Assert.assertTrue
12 +import org.junit.Test
13 +import org.neiam.diogramos.app.canvas.CanvasDocument
14 +import org.neiam.diogramos.app.canvas.CanvasSession
15 +import org.neiam.diogramos.app.canvas.DocumentOps
16 +import org.neiam.diogramos.app.canvas.DragState
17 +import org.neiam.diogramos.app.canvas.ElementType
18 +import org.neiam.diogramos.app.canvas.Ops
19 +import org.neiam.diogramos.app.canvas.Rect
20 +import org.neiam.diogramos.app.canvas.Ulid
21 +
22 +class UlidTest {
23 + @Test
24 + fun `matches the format the server generates`() {
25 + val id = Ulid.generate()
26 +
27 + // Diogramos.ULID: 26 chars of Crockford base32, no I L O U.
28 + assertEquals(26, id.length)
29 + assertTrue(id, id.matches(Regex("^[0-9A-HJKMNP-TV-Z]{26}$")))
30 + }
31 +
32 + @Test
33 + fun `sorts chronologically, which is the reason for the format`() {
34 + val earlier = Ulid.generate(1_700_000_000_000)
35 + val later = Ulid.generate(1_700_000_001_000)
36 +
37 + assertTrue("$earlier should sort before $later", earlier < later)
38 + }
39 +
40 + @Test
41 + fun `ids from the same millisecond still differ`() {
42 + val ids = (1..500).map { Ulid.generate(1_700_000_000_000) }.toSet()
43 +
44 + assertEquals(500, ids.size)
45 + }
46 +}
47 +
48 +class OpsTest {
49 + private val json = Json { ignoreUnknownKeys = true }
50 +
51 + private fun document(text: String) =
52 + CanvasDocument.parse(json.parseToJsonElement(text).jsonObject)
53 +
54 + private val doc = document(
55 + """
56 + {"elements":{
57 + "r":{"id":"r","type":"rect","x":0,"y":0,"w":100,"h":50},
58 + "c":{"id":"c","type":"circle","cx":200,"cy":200,"r":25},
59 + "t":{"id":"t","type":"text","x":0,"y":300,"w":80,"h":20}},
60 + "order":["r","c","t"],"connectors":{}}
61 + """.trimIndent(),
62 + )
63 +
64 + @Test
65 + fun `a rect geometry patch carries the box`() {
66 + val patch = Ops.geometryPatch(doc.elements["r"]!!, Rect(10.0, 20.0, 30.0, 40.0))
67 +
68 + assertEquals("10", patch["x"]!!.jsonPrimitive.content)
69 + assertEquals("30", patch["w"]!!.jsonPrimitive.content)
70 + assertNull(patch["cx"])
71 + }
72 +
73 + @Test
74 + fun `a circle geometry patch carries centre and radius`() {
75 + val patch = Ops.geometryPatch(doc.elements["c"]!!, Rect(0.0, 0.0, 60.0, 40.0))
76 +
77 + assertEquals("30", patch["cx"]!!.jsonPrimitive.content)
78 + assertEquals("20", patch["cy"]!!.jsonPrimitive.content)
79 + // The smaller side wins: a circle has one radius, and the server
80 + // has nowhere to put an ellipse.
81 + assertEquals("20", patch["r"]!!.jsonPrimitive.content)
82 + assertNull(patch["w"])
83 + }
84 +
85 + @Test
86 + fun `coordinates are rounded before they are sent`() {
87 + val patch = Ops.geometryPatch(doc.elements["r"]!!, Rect(1.23456789, 0.0, 10.0, 10.0))
88 +
89 + // Otherwise a drag writes fifteen decimal places into the
90 + // document, and every one is persisted and re-broadcast.
91 + assertEquals("1.23", patch["x"]!!.jsonPrimitive.content)
92 + }
93 +
94 + @Test
95 + fun `every op this client builds is one the server accepts`() {
96 + val raw = doc.raw
97 +
98 + val ops = listOf(
99 + Ops.insertElement(Ops.newElement(ElementType.RECT, 10.0, 10.0)),
100 + Ops.insertElement(Ops.newElement(ElementType.ROUNDED, 10.0, 10.0)),
101 + Ops.insertElement(Ops.newElement(ElementType.CIRCLE, 10.0, 10.0)),
102 + Ops.insertElement(Ops.newElement(ElementType.TEXT, 10.0, 10.0)),
103 + Ops.updateElement("r", Ops.geometryPatch(doc.elements["r"]!!, Rect(5.0, 5.0, 20.0, 20.0))),
104 + Ops.updateElement("r", Ops.labelPatch("Hello")),
105 + Ops.updateElement("r", Ops.stylePatch("fill", "success")),
106 + Ops.updateElement("r", Ops.stylePatch("fill", null)),
107 + Ops.setZOrder(listOf("c", "t", "r")),
108 + Ops.deleteElement("t"),
109 + )
110 +
111 + ops.forEach { op ->
112 + val result = DocumentOps.apply(raw, op)
113 + assertTrue(
114 + "server would reject $op: ${(result as? DocumentOps.Result.Err)?.reason}",
115 + result is DocumentOps.Result.Ok,
116 + )
117 + }
118 + }
119 +
120 + @Test
121 + fun `a new text element starts with visible text`() {
122 + val element = Ops.newElement(ElementType.TEXT, 0.0, 0.0)
123 +
124 + // An empty text box is invisible: the user taps Add text and
125 + // nothing appears to happen.
126 + assertEquals("Text", element["label"]!!.jsonPrimitive.content)
127 + }
128 +
129 + @Test
130 + fun `a new shape is centred where it was asked for`() {
131 + val rect = Ops.newElement(ElementType.RECT, 100.0, 200.0)
132 + assertEquals(100.0 - Ops.DEFAULT_WIDTH / 2, rect["x"]!!.jsonPrimitive.content.toDouble(), 0.01)
133 +
134 + val circle = Ops.newElement(ElementType.CIRCLE, 100.0, 200.0)
135 + assertEquals(100.0, circle["cx"]!!.jsonPrimitive.content.toDouble(), 0.01)
136 + }
137 +
138 + @Test
139 + fun `reorder moves the element and leaves the rest in place`() {
140 + assertEquals(listOf("c", "t", "r"), Ops.reorder(listOf("r", "c", "t"), "r", toFront = true))
141 + assertEquals(listOf("t", "r", "c"), Ops.reorder(listOf("r", "c", "t"), "t", toFront = false))
142 + }
143 +
144 + @Test
145 + fun `reorder returns null when it would change nothing`() {
146 + // Sending a no-op would still cost a version bump, a broadcast to
147 + // every peer and a row on the server.
148 + assertNull(Ops.reorder(listOf("r", "c", "t"), "t", toFront = true))
149 + assertNull(Ops.reorder(listOf("r", "c", "t"), "r", toFront = false))
150 + assertNull(Ops.reorder(listOf("r", "c"), "missing", toFront = true))
151 + }
152 +
153 + @Test
154 + fun `a style patch does not disturb the other style keys`() {
155 + val document = json.parseToJsonElement(
156 + """
157 + {"elements":{"a":{"id":"a","type":"rect","x":0,"y":0,"w":10,"h":10,
158 + "style":{"fill":"primary","stroke":"error"}}},
159 + "order":["a"],"connectors":{}}
160 + """.trimIndent(),
161 + ).jsonObject
162 +
163 + val result = DocumentOps.apply(
164 + document,
165 + Ops.updateElement("a", Ops.stylePatch("fill", "success")),
166 + ) as DocumentOps.Result.Ok
167 +
168 + val style = result.document["elements"]!!.jsonObject["a"]!!.jsonObject["style"]!!.jsonObject
169 + assertEquals("success", style["fill"]!!.jsonPrimitive.content)
170 + assertEquals("error", style["stroke"]!!.jsonPrimitive.content)
171 + }
172 +}
173 +
174 +class DragStateTest {
175 + private val origin = Rect(10.0, 10.0, 100.0, 60.0)
176 +
177 + @Test
178 + fun `moving keeps the size`() {
179 + val moved = DragState("a", DragState.Kind.MOVE, origin, origin).movedBy(5.0, -3.0)
180 +
181 + assertEquals(15.0, moved.current.x, 0.0)
182 + assertEquals(7.0, moved.current.y, 0.0)
183 + assertEquals(100.0, moved.current.w, 0.0)
184 + }
185 +
186 + @Test
187 + fun `each corner moves only its own edges`() {
188 + val se = DragState("a", DragState.Kind.RESIZE_SE, origin, origin).movedBy(10.0, 10.0)
189 + assertEquals(10.0, se.current.x, 0.0)
190 + assertEquals(110.0, se.current.w, 0.0)
191 + assertEquals(70.0, se.current.h, 0.0)
192 +
193 + val nw = DragState("a", DragState.Kind.RESIZE_NW, origin, origin).movedBy(10.0, 10.0)
194 + assertEquals(20.0, nw.current.x, 0.0)
195 + assertEquals(20.0, nw.current.y, 0.0)
196 + assertEquals(90.0, nw.current.w, 0.0)
197 + }
198 +
199 + @Test
200 + fun `dragging a handle past the far edge flips instead of inverting`() {
201 + // A negative width is rejected by the server, so without this the
202 + // drag would silently stop working halfway across.
203 + val flipped = DragState("a", DragState.Kind.RESIZE_SE, origin, origin)
204 + .movedBy(-200.0, -200.0)
205 +
206 + assertTrue(flipped.current.w >= DragState.MIN_SIZE)
207 + assertTrue(flipped.current.h >= DragState.MIN_SIZE)
208 + assertTrue(flipped.current.x <= origin.x)
209 + }
210 +
211 + @Test
212 + fun `a resize never goes below the minimum grabbable size`() {
213 + val tiny = DragState("a", DragState.Kind.RESIZE_SE, origin, origin).movedBy(-99.0, -59.0)
214 +
215 + assertTrue(tiny.current.w >= DragState.MIN_SIZE)
216 + assertTrue(tiny.current.h >= DragState.MIN_SIZE)
217 + }
218 +
219 + @Test
220 + fun `the drag is computed from the origin, not accumulated`() {
221 + // The gesture reports a running total, so applying it twice must
222 + // not double the movement.
223 + val state = DragState("a", DragState.Kind.MOVE, origin, origin)
224 +
225 + assertEquals(state.movedBy(10.0, 0.0).current, state.movedBy(10.0, 0.0).current)
226 + }
227 +}
228 +
229 +class EditingRoundTripTest {
230 + private val json = Json { ignoreUnknownKeys = true }
231 +
232 + @Test
233 + fun `an edit survives the optimistic apply, the echo and the acknowledgement`() {
234 + val session = CanvasSession().apply {
235 + onSnapshot(
236 + json.parseToJsonElement(
237 + """{"elements":{"a":{"id":"a","type":"rect","x":0,"y":0,"w":10,"h":10}},
238 + "order":["a"],"connectors":{}}""",
239 + ).jsonObject,
240 + 4,
241 + )
242 + }
243 +
244 + val op = Ops.updateElement("a", Ops.labelPatch("Renamed"))
245 + val edit = session.localOp(op)!!
246 +
247 + // On screen immediately...
248 + assertEquals(
249 + "Renamed",
250 + session.document["elements"]!!.jsonObject["a"]!!.jsonObject["label"]!!
251 + .jsonPrimitive.content,
252 + )
253 +
254 + // ...and the echo does not apply it a second time.
255 + assertEquals(CanvasSession.Outcome.Echo, session.onRemoteOp(op, 5, edit.actorRef))
256 + assertEquals(5L, session.version)
257 + assertEquals(0, session.pendingCount)
258 + }
259 +
260 + @Test
261 + fun `a refused edit leaves the document as it was`() {
262 + val before = json.parseToJsonElement(
263 + """{"elements":{"a":{"id":"a","type":"rect","x":0,"y":0,"w":10,"h":10}},
264 + "order":["a"],"connectors":{}}""",
265 + ).jsonObject
266 +
267 + val session = CanvasSession().apply { onSnapshot(before, 1) }
268 +
269 + val edit = session.localOp(Ops.updateElement("a", Ops.labelPatch("Nope")))!!
270 + assertTrue(session.rollback(edit.actorRef))
271 +
272 + assertEquals(before, session.document)
273 + }
274 +
275 + @Test
276 + fun `deleting a shape drops the connectors hanging off it`() {
277 + val session = CanvasSession().apply {
278 + onSnapshot(
279 + json.parseToJsonElement(
280 + """
281 + {"elements":{"a":{"id":"a","type":"rect","x":0,"y":0,"w":10,"h":10},
282 + "b":{"id":"b","type":"rect","x":50,"y":0,"w":10,"h":10}},
283 + "order":["a","b"],
284 + "connectors":{"k":{"id":"k","from":{"element":"a"},"to":{"element":"b"}}}}
285 + """.trimIndent(),
286 + ).jsonObject,
287 + 2,
288 + )
289 + }
290 +
291 + assertNotNull(session.localOp(Ops.deleteElement("a")))
292 +
293 + assertTrue(session.document["connectors"]!!.jsonObject.isEmpty())
294 + assertEquals(1, session.document["order"]!!.jsonArray.size)
295 + }
296 +}
modified docs/android.md
+25 −3
@@ -566,9 +566,31 @@ Rejections are ported as carefully as the successes. An op the server
566 566 would refuse must not appear to work on the phone — otherwise an
567 567 optimistic edit sits on screen looking saved and silently never lands.
568 568
569 −### Phase 4 — Light editing
570 −- Selection, move, resize, label/text edit, color picks, add shape, delete,
571 − z-order. Optimistic apply + rollback.
569 +### Phase 4 — Light editing ✅ shipped
570 +- Tap to select, tap again for the sheet; drag to move, corner handles to
571 + resize; label/text, fill/stroke/label colour, bring to front, send to
572 + back, delete; a FAB to add each of the four shape types.
573 +- `canvas/Ops.kt` — the op builders, with coordinates rounded to two
574 + decimals before they go out.
575 +- `canvas/DragState.kt` — move and resize maths, including the flip when
576 + a handle is dragged past the opposite edge.
577 +- `canvas/Ulid.kt` — element ids in the server's format.
578 +- `CanvasSender` — ops pushed with their `actor_ref` and the reply acted
579 + on; cursor and lock published while a drag is in flight.
580 +- 64 Android unit tests in total.
581 +
582 +A drag is not written into the document while the finger is down. Each op
583 +is a `GenServer.call`, a broadcast to every peer and a WAL row, so the
584 +in-flight geometry is a render-time override and ops go out on a ~10/s
585 +throttle plus once at the end. Connectors re-route against the preview,
586 +so they follow the shape instead of snapping when it lands.
587 +
588 +Gesture detectors are deliberately not keyed on the document revision: a
589 +drag sends ops as it goes, and restarting the `pointerInput` on the
590 +resulting recomposition cancels the gesture that produced them.
591 +
592 +A viewer gets no handles, no add button and no editing controls. An
593 +action that is always refused is worse than no button at all.
572 594
573 595 ### Phase 5 — Polish
574 596 - Export (JSON + mermaid) to the share sheet, canvas create/rename/theme,

Parents: 7f6cefe