neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Android client phase 4: editing on the phone
e61db36 · Gabriel Morell · 2026-09-14 22:48
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