diff --git a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidComposerMenuTest.kt b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidComposerMenuTest.kt index 57d453d2..9a5f7e23 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidComposerMenuTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidComposerMenuTest.kt @@ -6,8 +6,11 @@ import android.view.MotionEvent import android.view.inspector.WindowInspector import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsSelected @@ -47,7 +50,7 @@ class AndroidComposerMenuTest { @After fun closeBridge() { bridge?.let { ui.runOnIdle { it.close() } } } - private fun composer() { + private fun composer(scheme: ColorScheme = LogseqTheme.lightScheme) { lateinit var native: LogseqNativeBridge val backend = LuiBackend( onEvent = { dispatchLuiEvent(it, native) }, @@ -62,14 +65,14 @@ class AndroidComposerMenuTest { filter.applyJson(native.applySnapshot("""{"ok":true,"result":{ "graphName":"Composer test","selectedGraphId":"composer-test", "isGraphUnlocked":true,"outlinerRows":[]}}""")) - content(backend, root) + content(backend, root, scheme) ui.onNodeWithTag("button.composer.expand").performClick() ui.onNodeWithTag("field.composer").performTextInput("Unsent draft δΈ­ζ–‡") } - private fun content(backend: LuiBackend, root: Long) { + private fun content(backend: LuiBackend, root: Long, scheme: ColorScheme = LogseqTheme.lightScheme) { ui.setContent { - MaterialTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) { + MaterialTheme(colorScheme = scheme, typography = LogseqTheme.typography) { Box(Modifier.fillMaxSize()) { backend.Content(root) } } } @@ -139,6 +142,25 @@ class AndroidComposerMenuTest { .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton)) } + @Test fun selectedMenuItemUsesTheLightSelectionPalette() { + assertSelectedMenuColor(LogseqTheme.lightScheme) + } + + @Test fun selectedMenuItemUsesTheDarkSelectionPalette() { + assertSelectedMenuColor(LogseqTheme.darkScheme) + } + + private fun assertSelectedMenuColor(scheme: ColorScheme) { + composer(scheme) + val status = ui.onNodeWithTag("button.task-status") + val todo = ui.onNodeWithText("Todo") + status.performClick() + todo.performClick() + status.performClick() + val pixels = todo.assertIsSelected().captureToImage().toPixelMap() + assertEquals(scheme.secondaryContainer, pixels[pixels.width * 9 / 10, pixels.height / 2]) + } + @Test fun tappingOutsideTheComposerClosesItAndPreservesTheDraft() { composer() ui.onNodeWithTag("surface.composer.dismiss").performClick() diff --git a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidEditorKeyboardTest.kt b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidEditorKeyboardTest.kt index 88056f2b..4b507e33 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidEditorKeyboardTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidEditorKeyboardTest.kt @@ -2,6 +2,7 @@ package com.logseq.logseq import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.text.TextRange @@ -68,8 +69,10 @@ class AndroidEditorKeyboardTest { editor.performKeyInput { pressKey(Key.Enter) } acknowledge("keyboard-block", "Hello ", 6) editor.assertTextEquals("Hello δΈ­ζ–‡ πŸ“") + editor.assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(6))) acknowledge("next-block", "δΈ­ζ–‡ πŸ“", 0) ui.onNodeWithTag("field.outliner.block.next-block").assertIsFocused().assertTextEquals("δΈ­ζ–‡ πŸ“") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(0))) } @Test fun typingDuringSplitIsMergedAtTheNewCaretAndPublishedForTheNewBlock() { @@ -77,6 +80,7 @@ class AndroidEditorKeyboardTest { editor.performTextInputSelection(TextRange(6)) editor.performKeyInput { pressKey(Key.Enter) } editor.performTextInput("ζ–°πŸ“") + editor.assertTextEquals("Hello δΈ­ζ–‡ πŸ“") acknowledge("keyboard-block", "Hello ", 6) acknowledge("next-block", "δΈ­ζ–‡ πŸ“", 0) ui.onNodeWithTag("field.outliner.block.next-block").assertIsFocused().assertTextEquals("ζ–°πŸ“δΈ­ζ–‡ πŸ“") @@ -129,12 +133,33 @@ class AndroidEditorKeyboardTest { } finally { ui.mainClock.autoAdvance = true } } + @Test fun tappingAnotherBlockAppliesItsTextAndCaretInOneFrame() { + render("Source block") + ui.mainClock.autoAdvance = false + try { + ui.runOnUiThread { + val ops = JSONArray() + mapOf("block-id" to "target-block", "title" to "Target δΈ­ζ–‡ πŸ“", + "caret-utf16-offset" to 7).forEach { (name, value) -> + ops.put(JSONObject().put("op", "set-extension-prop").put("id", 2) + .put("property", name).put("value", value)) + } + assertTrue(backend.applyJson(JSONObject().put("generation", ++generation).put("ops", ops).toString())) + } + ui.mainClock.advanceTimeByFrame() + ui.onNodeWithTag("field.outliner.block.target-block").assertIsFocused().assertTextEquals("Target δΈ­ζ–‡ πŸ“") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange(7))) + } finally { ui.mainClock.autoAdvance = true } + } + @Test fun softwareKeyboardNextCreatesOneBlockWithoutCommittingANewline() { - val editor = render("Native keyboard") + val editor = render("") assertEquals(android.view.inputmethod.EditorInfo.IME_ACTION_NEXT, editorInfo().imeOptions and android.view.inputmethod.EditorInfo.IME_MASK_ACTION) + editor.performTextInput("Native keyboard") editor.performImeAction() editor.assertTextEquals("Native keyboard") + editor.assert(SemanticsMatcher.expectValue(SemanticsProperties.TextSelectionRange, TextRange("Native keyboard".length))) ui.runOnIdle { assertEquals(1, events.filterIsInstance().count { it.name == "return" }) } } diff --git a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerSpacingTest.kt b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerSpacingTest.kt index 08cdcd2f..ccb91d6b 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerSpacingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerSpacingTest.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.test.captureToImage import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed @@ -15,6 +17,9 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.click import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -45,7 +50,7 @@ class AndroidOutlinerSpacingTest { @After fun closeBridge() { bridge?.let { ui.runOnIdle { it.close() } } } - private fun snapshot(editing: Boolean = false, priority: Boolean = false, status: Boolean = false, dates: Boolean = false, hasChildren: Boolean = true): String { + private fun snapshot(editing: Boolean = false, priority: Boolean = false, status: Boolean = false, dates: Boolean = false, hasChildren: Boolean = true, editingUuid: String = "parent"): String { fun row(id: String, text: String, depth: Int): JSONObject { val block = JSONObject().put("uuid", id).put("title", text).put("pageId", "spacing-journal") .put("journalTitle", journal).put("journalDay", 20261022) @@ -61,8 +66,8 @@ class AndroidOutlinerSpacingTest { return JSONObject().put("depth", depth).put("hasChildren", depth == 0 && hasChildren).put("block", block) } val state = JSONObject() - if (editing) state.put("editing", JSONObject().put("uuid", "parent") - .put("title", "Parent note").put("caretUTF16Offset", 0)) + if (editing) state.put("editing", JSONObject().put("uuid", editingUuid) + .put("title", if (editingUuid == "parent") "Parent note" else "Child note").put("caretUTF16Offset", 0)) return JSONObject().put("ok", true).put("result", JSONObject() .put("graphName", "Spacing test").put("selectedGraphId", "spacing-test") .put("isGraphUnlocked", true).put("outlinerState", state) @@ -101,6 +106,45 @@ class AndroidOutlinerSpacingTest { assertTrue("Editing does not reserve an empty priority gap: ${gap(true)}", gap(true) in 0.dp..4.dp) } + @Test fun tappingBlockTextKeepsTheTextLayoutWhenTheEditorOpens() { + render() + val content = ui.onNodeWithTag("outliner-block-content-parent") + val body = ui.onNodeWithText("Parent note") + val before = mutableListOf() + body.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(before) } + val initialLeft = body.getUnclippedBoundsInRoot().left + content.performTouchInput { click(center) } + ui.runOnIdle { + assertTrue(events.filterIsInstance().any { it.name == "edit" }) + filter.applyJson(requireNotNull(bridge).applySnapshot(snapshot(editing = true))) + } + val editor = ui.onNodeWithTag("field.outliner.block.parent").assertIsFocused() + val after = mutableListOf() + editor.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(after) } + assertEquals(initialLeft, editor.getUnclippedBoundsInRoot().left) + assertEquals(before.single().getLineRight(0), after.single().getLineRight(0), 0.5f) + assertEquals(before.single().firstBaseline, after.single().firstBaseline, 0.5f) + } + + @Test fun tappingBetweenBlocksKeepsEachTitleInTheFirstEditorFrame() { + render(editing = true) + val editorId = ui.onNodeWithTag("field.outliner.block.parent").assertIsFocused().fetchSemanticsNode().id + for (uuid in listOf("child", "parent", "child")) { + ui.onNodeWithTag("outliner-block-content-$uuid").performTouchInput { click(center) } + ui.mainClock.autoAdvance = false + try { + ui.runOnUiThread { + filter.applyJson(requireNotNull(bridge).applySnapshot(snapshot(editing = true, editingUuid = uuid))) + } + ui.mainClock.advanceTimeByFrame() + val editor = ui.onNodeWithTag("field.outliner.block.$uuid").assertIsFocused() + .assertTextEquals(if (uuid == "parent") "Parent note" else "Child note") + assertEquals("Switching blocks reuses the native editor", editorId, editor.fetchSemanticsNode().id) + ui.onNodeWithTag("outliner-block-content-${if (uuid == "parent") "child" else "parent"}").assertIsDisplayed() + } finally { ui.mainClock.autoAdvance = true } + } + } + @Test fun theBulletCenterAlignsWithTheJournalTitleLeadingEdge() { render() val heading = ui.onAllNodesWithText(journal, useUnmergedTree = true).fetchSemanticsNodes() diff --git a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerToolbarTest.kt b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerToolbarTest.kt index 013deb36..f2a3f23d 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerToolbarTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidOutlinerToolbarTest.kt @@ -9,11 +9,15 @@ import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick @@ -116,6 +120,11 @@ class AndroidOutlinerToolbarTest { } capture("editor-overflow") reference.assertIsDisplayed() + ui.onNode( + hasContentDescription("Page reference") and + hasAnyAncestor(hasTestTag("button.outliner.editor.pageReference")), + useUnmergedTree = true, + ).assertHeightIsEqualTo(24.dp) assertEquals(before, hide.fetchSemanticsNode().boundsInRoot) ui.runOnIdle { events.clear() } hide.performClick() diff --git a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidReviewLayoutTest.kt b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidReviewLayoutTest.kt index a709fea1..e79f532a 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidReviewLayoutTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/logseq/AndroidReviewLayoutTest.kt @@ -48,7 +48,7 @@ class AndroidReviewLayoutTest { @After fun closeBridge() { bridge?.let { ui.runOnIdle { it.close() } } } - private fun app(error: String? = null) { + private fun app(error: String? = null, projection: JSONObject? = null) { lateinit var native: LogseqNativeBridge val backend = LuiBackend(onEvent = { events.add(it); dispatchLuiEvent(it, native) }, extensions = logseqExtensionRegistry { it }, icons = logseqIconResolver) @@ -59,6 +59,7 @@ class AndroidReviewLayoutTest { val result = JSONObject().put("graphName", "Review graph").put("selectedGraphId", "review-layout") .put("isGraphUnlocked", true).put("syncConnected", false).put("outlinerRows", JSONArray()) if (error != null) result.put("pendingSyncError", error) + projection?.keys()?.forEach { result.put(it, projection.get(it)) } filter.applyJson(native.applySnapshot(JSONObject().put("ok", true).put("result", result).toString())) ui.setContent { MaterialExpressiveTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) { @@ -80,6 +81,41 @@ class AndroidReviewLayoutTest { ui.onNodeWithTag("button.graph-add").performClick() } + @Test fun completedFlashcardReviewIsCenteredAcrossTheScreen() { + app(projection = JSONObject().put("flashcardDecks", + JSONObject().put("completed", true).put("totalCards", 1))) + ui.onNodeWithTag("button.sidebar").performClick() + ui.onNodeWithTag("link.sidebar.flashcards").performClick() + val screen = ui.onNodeWithTag("screen.flashcards").fetchSemanticsNode().boundsInRoot + val complete = ui.onNodeWithTag("flashcards.complete").fetchSemanticsNode().boundsInRoot + assertEquals("The completion state is centered on the screen", screen.center.x, complete.center.x, 1f) + } + + @Test fun graphUnlockFormFillsTheSheetWithEvenInsets() { + app(projection = JSONObject().put("isGraphEncrypted", true).put("isGraphUnlocked", false)) + val sheet = ui.onNodeWithTag("sheet.graph-unlock").getUnclippedBoundsInRoot() + val field = ui.onNodeWithTag("field.graph-password").getUnclippedBoundsInRoot() + assertTrue("The password field has a leading inset", field.left - sheet.left >= 20.dp) + assertTrue("The password field fills the sheet", sheet.right - field.right <= 32.dp) + ui.onNodeWithTag("field.graph-password").performClick().performTextInput("local-layout-check") + ui.onNodeWithTag("button.graph-unlock.cancel").assertIsDisplayed() + ui.onNodeWithTag("button.graph-unlock").assertIsDisplayed() + } + + @Test fun sidebarFavoritesAndRecentPagesUseCompactRows() { + fun pages(prefix: String) = JSONArray().apply { + repeat(3) { put(JSONObject().put("uuid", "$prefix-$it").put("title", "$prefix $it")) } + } + app(projection = JSONObject().put("favorites", pages("favorite")).put("recentPages", pages("recent"))) + ui.onNodeWithTag("button.sidebar").performClick() + for (prefix in listOf("favorite", "recent")) { + val first = ui.onNodeWithTag("link.sidebar.page.$prefix-0").getUnclippedBoundsInRoot() + val second = ui.onNodeWithTag("link.sidebar.page.$prefix-1").getUnclippedBoundsInRoot() + assertTrue("Adjacent $prefix rows stay compact", second.top - first.top <= 50.dp) + assertTrue("Page rows retain a 48 dp height", first.bottom - first.top >= 48.dp) + } + } + @Test fun graphActionsAreVisibleOnFirstOpen() { graphForm() ui.onNodeWithTag("button.graph-add.cancel").assertIsDisplayed() diff --git a/android/app/src/main/kotlin/com/logseq/logseq/runtime/LogseqIcons.kt b/android/app/src/main/kotlin/com/logseq/logseq/runtime/LogseqIcons.kt index 2679d58a..f03f55b6 100644 --- a/android/app/src/main/kotlin/com/logseq/logseq/runtime/LogseqIcons.kt +++ b/android/app/src/main/kotlin/com/logseq/logseq/runtime/LogseqIcons.kt @@ -135,6 +135,30 @@ private val copyReference: ImageVector = ImageVector.Builder( lineTo(15f, 13f) }.build() +private val pageReference: ImageVector = ImageVector.Builder( + name = "PageReference", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.8f, + strokeLineJoin = androidx.compose.ui.graphics.StrokeJoin.Round, +) { + fun bracket(edge: Float, tip: Float) { + moveTo(tip, 4f) + lineTo(edge, 4f) + lineTo(edge, 20f) + lineTo(tip, 20f) + } + bracket(2.5f, 5.5f) + bracket(8.5f, 11f) + bracket(15.5f, 13f) + bracket(21.5f, 18.5f) +}.build() + // App icon vocabulary (the former logseq_icons.dart) mapped onto // Material Symbols vectors β€” same symbolic names the core emits, matched // to the SF Symbols / asset meanings the iOS renderer uses. @@ -204,6 +228,7 @@ internal val logseqAppIcons: Map = mapOf( "toolbar-move-down" to Icons.Filled.ArrowDownward, "toolbar-move-up" to Icons.Filled.ArrowUpward, "toolbar-outdent" to Icons.AutoMirrored.Filled.ArrowBack, + "toolbar-page-reference" to pageReference, "toolbar-tag" to Icons.Outlined.Tag, "toolbar-task" to Icons.Outlined.CheckCircle, "toolbar-unselect" to Icons.Filled.Clear, diff --git a/android/app/src/main/kotlin/com/logseq/logseq/ui/LogseqTheme.kt b/android/app/src/main/kotlin/com/logseq/logseq/ui/LogseqTheme.kt index 6ea6b630..75bddbb5 100644 --- a/android/app/src/main/kotlin/com/logseq/logseq/ui/LogseqTheme.kt +++ b/android/app/src/main/kotlin/com/logseq/logseq/ui/LogseqTheme.kt @@ -58,6 +58,12 @@ internal object LogseqTheme { val lightSurface = Color(0xfff8f9ff) val darkSurface = Color(0xff101318) + // Material Expressive menus read the tertiary container roles for selection. + private fun ColorScheme.withMenuSelectionColors() = copy( + tertiaryContainer = secondaryContainer, + onTertiaryContainer = onSecondaryContainer, + ) + val lightScheme = lightColorScheme( primary = accent, onPrimary = Color.White, @@ -75,7 +81,7 @@ internal object LogseqTheme { surfaceContainer = Color(0xffeceef4), surfaceContainerHigh = Color(0xffe6e8ef), surfaceContainerHighest = Color(0xffe0e2ea), - ) + ).withMenuSelectionColors() val darkScheme = darkColorScheme( primary = Color(0xff9bd3d4), @@ -92,7 +98,7 @@ internal object LogseqTheme { surfaceContainer = Color(0xff181c22), surfaceContainerHigh = Color(0xff1d2229), surfaceContainerHighest = Color(0xff22272f), - ) + ).withMenuSelectionColors() fun semanticColors(scheme: ColorScheme): Map = mapOf( "background" to scheme.background, diff --git a/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerBlockContent.kt b/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerBlockContent.kt index 320c0a8e..69afeab0 100644 --- a/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerBlockContent.kt +++ b/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerBlockContent.kt @@ -106,7 +106,7 @@ import org.json.JSONObject // android_embedded_media.dart, android_math.dart, android_cloze.dart. // Block text matches iOS `.body` (17pt, ~22pt leading). -private val outlinerBodyStyle: androidx.compose.ui.text.TextStyle +internal val outlinerBodyStyle: TextStyle @Composable get() = MaterialTheme.typography.bodyLarge.copy( fontSize = if (LocalPropertyValue.current) 16.sp else 17.sp, lineHeight = if (LocalPropertyValue.current) 21.sp else 22.sp, diff --git a/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerEditor.kt b/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerEditor.kt index 44eba0bf..28db7da2 100644 --- a/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerEditor.kt +++ b/android/app/src/main/kotlin/com/logseq/logseq/ui/extensions/OutlinerEditor.kt @@ -1,8 +1,10 @@ package com.logseq.logseq.ui.extensions import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.material3.MaterialTheme @@ -45,7 +47,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.compose.ui.text.font.FontFamily import androidx.compose.foundation.background @@ -69,18 +70,31 @@ private sealed interface EditorHandoff { } private class EditorFieldValue(private val state: TextFieldState) { + var prefix: String = "" + + fun read(): TextFieldValue { + val offset = prefix.length + fun TextRange.afterPrefix() = TextRange((start - offset).coerceAtLeast(0), (end - offset).coerceAtLeast(0)) + return TextFieldValue( + state.text.drop(offset).toString(), state.selection.afterPrefix(), state.composition?.afterPrefix(), + ) + } + operator fun getValue(owner: Any?, property: kotlin.reflect.KProperty<*>): TextFieldValue = - TextFieldValue(state.text.toString(), state.selection, state.composition) + read() operator fun setValue(owner: Any?, property: kotlin.reflect.KProperty<*>, next: TextFieldValue) { - if (state.text.toString() == next.text && state.selection == next.selection) return + val text = prefix + next.text + val selection = TextRange(prefix.length + next.selection.start, prefix.length + next.selection.end) + if (state.text.toString() == text && state.selection == selection) return state.edit { - if (asCharSequence().toString() != next.text) replace(0, length, next.text) - selection = next.selection + if (asCharSequence().toString() != text) replace(0, length, text) + this.selection = selection } } } +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun OutlinerEditor(context: LuiExtensionContext) { val blockId = context.string("block-id") ?: "" @@ -104,6 +118,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { var rememberedSelectionRevision by remember { mutableIntStateOf(selectionRevision) } var rememberedBlockId by remember { mutableStateOf(blockId) } var awaitingHandoff by remember { mutableStateOf(false) } + var retainedReturn by remember { mutableStateOf(null) } // Each segment buffers native edits after a structural edit until acknowledgement. val handoffSegments = remember { ArrayDeque() } val queuedHandoffs = remember { ArrayDeque() } @@ -120,6 +135,17 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val publishedTitles = remember { mutableStateListOf() } var handoffEvent by remember { mutableStateOf(null) } + fun applyNativeValue(next: TextFieldValue) { + // Snapshot observers can run during the edit. Mark it before writing. + observedValue = next + value = next + } + + fun keepPrefix(prefix: String, input: TextFieldValue) { + nativeValue.prefix = prefix + applyNativeValue(input) + } + fun capturePendingInput(incoming: TextFieldValue) { if (!awaitingHandoff) return handoffSegments.lastOrNull()?.capture(incoming.text, incoming.selection.start, incoming.selection.end) @@ -130,7 +156,8 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { // new text, caret and identity together instead of waiting another frame. val handoffAcknowledged = handoffRevision != null && selectionRevision != handoffRevision if (awaitingHandoff && value.composition == null && (blockId != rememberedBlockId || handoffAcknowledged)) { - capturePendingInput(value) + val buffered = value + capturePendingInput(buffered) handoffRevision = null val pending = handoffSegments.removeFirstOrNull() val offset = modelCaret.coerceIn(0, modelTitle.length) @@ -143,20 +170,24 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { rememberedSelectionRevision = selectionRevision publishedTitles.clear() awaitingHandoff = handoffSegments.isNotEmpty() + retainedReturn = null if (awaitingHandoff) { handoffRevision = selectionRevision publishedTitles.add(merged) handoffEvent = when (val queued = queuedHandoffs.removeFirst()) { - is EditorHandoff.Return -> EditorHandoff.Return(merged, caret) - is EditorHandoff.Paste -> EditorHandoff.Paste( - rebasePastePayload(queued.payload, merged), - ) + is EditorHandoff.Return -> { + val nextReturn = EditorHandoff.Return(merged, caret) + retainedReturn = nextReturn + keepPrefix(merged.take(caret), buffered) + nextReturn + } + is EditorHandoff.Paste -> { + keepPrefix("", buffered) + EditorHandoff.Paste(rebasePastePayload(queued.payload, merged)) + } } } else { - if (value.text != merged || value.selection != selection) { - value = TextFieldValue(merged, selection) - } - observedValue = value + keepPrefix("", TextFieldValue(merged, selection)) if (merged != modelTitle) debounceSeq++ } } @@ -172,9 +203,8 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } } - // Model β†’ field sync (didUpdateWidget equivalent). - LaunchedEffect(blockId, modelTitle, modelCaret, selectionRevision) { - if (awaitingHandoff) return@LaunchedEffect + // A reused editor must show its new block before this frame is drawn. + if (!awaitingHandoff) { val blockChanged = blockId != rememberedBlockId rememberedBlockId = blockId val selectionRequested = selectionRevision != rememberedSelectionRevision @@ -184,19 +214,17 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val acknowledged = if (blockChanged) -1 else publishedTitles.indexOf(modelTitle) if (acknowledged >= 0) { repeat(acknowledged + 1) { publishedTitles.removeAt(0) } - // An older echo must not replace newer typing or active IME composition. - if (!selectionRequested || value.text != modelTitle || value.composition != null) { - return@LaunchedEffect - } } + // An older echo must not replace newer typing or active IME composition. + val preserveNativeInput = acknowledged >= 0 && + (!selectionRequested || value.text != modelTitle || value.composition != null) // Caret-only echoes preserve newer native selection; explicit requests change selectionRevision. - if (blockChanged || selectionRequested || titleChanged) { + if (!preserveNativeInput && (blockChanged || selectionRequested || titleChanged)) { if (titleChanged) publishedTitles.clear() applyingModel = true debounceSeq++ val offset = modelCaret.coerceIn(0, modelTitle.length) - value = TextFieldValue(modelTitle, TextRange(offset)) - observedValue = value + applyNativeValue(TextFieldValue(modelTitle, TextRange(offset))) applyingModel = false } } @@ -234,9 +262,10 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { fun submitReturn(title: String, caret: Int) { capturePendingInput(TextFieldValue(title, TextRange(caret))) - // Rebase the native field before later IME commits can target the old block. - value = TextFieldValue(title.substring(caret.coerceIn(0, title.length)), TextRange(0)) - observedValue = value + val split = caret.coerceIn(0, title.length) + if (!awaitingHandoff) retainedReturn = EditorHandoff.Return(title, split) + // Keep the real caret at the split. The input for the next block starts there. + keepPrefix(nativeValue.prefix + title.take(split), TextFieldValue(title.drop(split), TextRange(0))) if (awaitingHandoff) { queuedHandoffs.addLast(EditorHandoff.Return(title, caret)) handoffSegments.addLast(PendingEditorInput(value.text, value.selection.start)) @@ -337,8 +366,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { val submitted = title.removeRange(splitAt, splitAt + 1) val submittedCaret = splitAt.coerceIn(0, submitted.length) applyingModel = true - value = TextFieldValue(submitted, TextRange(submittedCaret)) - observedValue = value + applyNativeValue(TextFieldValue(submitted, TextRange(submittedCaret))) applyingModel = false submitReturn(submitted, submittedCaret) } else { @@ -369,7 +397,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } val onNativeChange by rememberUpdatedState(::acceptValue) LaunchedEffect(fieldState) { - snapshotFlow { TextFieldValue(fieldState.text.toString(), fieldState.selection, fieldState.composition) } + snapshotFlow { nativeValue.read() } .collect { onNativeChange(it) } } val completionToolbar = remember(nativeToolbar, value, selectionRevision) { @@ -387,18 +415,36 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { CompositionLocalProvider(LocalTextToolbar provides completionToolbar) { Box(if (code) Modifier.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) .padding(12.dp).testTag("outliner.code-editor.$rememberedBlockId") else Modifier) { - val highlighted = if (code) rememberHighlightedCode(value.text, codeLanguage) else value.annotatedString + val highlighted = if (code) rememberHighlightedCode(retainedReturn?.title ?: value.text, codeLanguage) else null val highlightedState = rememberUpdatedState(highlighted) + val inputTransformation = remember { + InputTransformation { + val boundary = nativeValue.prefix.length + if (boundary > 0) { + if ((0 until changes.changeCount).any { changes.getOriginalRange(it).min < boundary }) { + revertAllChanges() + } + selection = TextRange(selection.start.coerceAtLeast(boundary), selection.end.coerceAtLeast(boundary)) + } + } + } val outputTransformation = remember { OutputTransformation { + // Buffer edits to the next block while displaying the source row. + retainedReturn?.let { source -> + if (asCharSequence().toString() != source.title) { + replace(source.caret, length, source.title.drop(source.caret)) + } + } val styled = highlightedState.value - if (styled.text == asCharSequence().toString()) { + if (styled != null && styled.text == asCharSequence().toString()) { styled.spanStyles.forEach { addStyle(it.item, it.start, it.end) } } } } BasicTextField( state = fieldState, + inputTransformation = inputTransformation, outputTransformation = outputTransformation, modifier = Modifier .fillMaxWidth() @@ -434,8 +480,7 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { } else { debounceSeq++ } - value = next - observedValue = value + applyNativeValue(next) true } else if (!multiline && event.type == KeyEventType.KeyDown && event.key == Key.Enter) { // Handle hardware Enter before the IME can batch several keys into pasted text. @@ -452,12 +497,9 @@ internal fun OutlinerEditor(context: LuiExtensionContext) { false } }, - textStyle = MaterialTheme.typography.bodyLarge.copy( + textStyle = outlinerBodyStyle.copy( color = MaterialTheme.colorScheme.onSurface, - // Match iOS `.body` (17pt, ~22pt leading). - fontSize = 17.sp, - fontFamily = if (code) FontFamily.Monospace else FontFamily.Default, - lineHeight = 22.sp, + fontFamily = if (code) FontFamily.Monospace else outlinerBodyStyle.fontFamily, ), keyboardOptions = KeyboardOptions( capitalization = if (code) KeyboardCapitalization.None else KeyboardCapitalization.Sentences, diff --git a/shared/src/logseq/model.ml b/shared/src/logseq/model.ml index 22900ead..00f1ccad 100644 --- a/shared/src/logseq/model.ml +++ b/shared/src/logseq/model.ml @@ -1453,16 +1453,20 @@ let node_permission (current : logseq_model) uuid permission = | _ -> true let active_delete_target (current : logseq_model) = - let routes = if current.search_open then - app_node_routes_from current current.search_navigation_path 0 - else app_node_routes current in - match last_opt routes with - | Some route when route.node_uuid <> route.node_page_uuid -> - if current.applied_server_t <> None && node_permission current route.node_uuid "canDelete" then - Some (BlockToDelete {uuid = route.node_uuid; title = route.node_title}) - else None - | _ -> Option.bind (active_page current) (fun page -> + let path = if current.search_open then current.search_navigation_path else current.app_navigation_path in + match last_opt path with + | None -> Option.bind (active_page current) (fun page -> if node_permission current page.uuid "canDelete" then Some (PageToDelete page) else None) + | Some requested -> + let uuid = navigation_route_uuid requested in + (* The displayed stack can lag behind a new route request. *) + match last_opt (app_node_routes_from current path 0) with + | Some route when route.node_uuid = uuid && node_permission current uuid "canDelete" -> + let node = { uuid; title = route.node_title } in + if uuid = route.node_page_uuid then Some (PageToDelete node) + else if current.applied_server_t <> None then Some (BlockToDelete node) + else None + | _ -> None let node_can_set_tags current uuid = node_permission current uuid "canAdd" let page_is_favorite_ (current : logseq_model) uuid = diff --git a/shared/src/logseq/view_authentication.ml b/shared/src/logseq/view_authentication.ml index 41b4bb42..b9b3de6e 100644 --- a/shared/src/logseq/view_authentication.ml +++ b/shared/src/logseq/view_authentication.ml @@ -113,7 +113,8 @@ let sheet source send : t = (row ~cross:`center ~corner_radius:28 ~background_signal:(Signal.map (fun enabled -> if enabled then "auth-action" else "auth-disabled") can_submit) [button ~variant:`ghost ~min_height:54 ~size:`lg ~grow:1.0 - ~accessibility_identifier:"button.auth.submit" ~text_alignment:`center + ~accessibility_identifier:"button.auth.submit" + ?text_alignment:(if Lui_ui.host context = KotlinHost then None else Some `center) ~disabled_signal:(Signal.map not can_submit) ~text_signal:(Signal.map (fun current -> tr source (Auth_flow.submit_label (form current))) source) ~on_press:(action send Auth_flow.Submit) [ diff --git a/shared/src/logseq/view_flashcards.ml b/shared/src/logseq/view_flashcards.ml index 41c960b9..c4a8c5ba 100644 --- a/shared/src/logseq/view_flashcards.ml +++ b/shared/src/logseq/view_flashcards.ml @@ -270,7 +270,8 @@ let flashcard_screen model_source send : t = flashcard_group_controls model_source send; if_ ~test:(Signal.map View_base.flashcards_empty_ model_source) - (column ~accessibility_identifier:"layout.flashcards.empty" + (fun context parent -> + let content = column ~accessibility_identifier:"layout.flashcards.empty" ~grow:1.0 ~main:`center ~cross:`center ~gap:10 ~padding:32 [ if_ ~test:(Signal.map review_completed model_source) @@ -299,7 +300,10 @@ let flashcard_screen model_source send : t = List.exists (function Model.FlashcardDeckEffect _ -> true | _ -> false) (model.pending_effects @ model.in_flight_effects)) model_source) ~on_press:(fun _ -> deck_request model_source send ["action", `String "practice"]) [])]); - ]); + ] in + (if Lui_ui.host context = Lui_protocol.KotlinHost then + row ~grow:1.0 ~main:`space_between [content] + else content) context parent); if_ ~test:(Signal.map View_base.flashcards_present_ model_source) (flashcard_review_content model_source send); diff --git a/shared/src/logseq/view_graphs.ml b/shared/src/logseq/view_graphs.ml index 150e53e9..7dc37b6c 100644 --- a/shared/src/logseq/view_graphs.ml +++ b/shared/src/logseq/view_graphs.ml @@ -390,38 +390,43 @@ let graph_password_sheet (context : Lui_ui.ui_context) model_source send ~accessibility_identifier:"sheet.graph-unlock" ~on_dismiss:(press send Model.CancelGraphUnlock) [ - column ~gap:20 ~cross:`stretch ~grow:1.0 - ~accessibility_identifier:"form.graph-unlock" + column ~gap:0 ~cross:`stretch ~grow:1.0 [ - secure_field - ~text_signal: - (Signal.map View_base.model_graph_password model_source) - ~placeholder:(View_base.tr (Signal.sample model_source) "E2EE password") ~label:(View_base.tr (Signal.sample model_source) "E2EE password") - ~accessibility_identifier:"field.graph-password" - ~on_input: - (on_input send (fun text -> - Model.ChangeGraphPassword text)) - []; - text ~style_class:"footnote" ~foreground:"muted-foreground" - ~value:(View_base.tr (Signal.sample model_source) "Enter your E2EE password to unlock this graph.") []; - - spacer ~grow:1.0 []; - toolbar ~orientation:`horizontal ~label:(View_base.tr (Signal.sample model_source) "Graph unlock actions") - ~accessibility_identifier:"toolbar.graph-unlock" - [ - spacer ~grow:1.0 []; - button ~variant:`ghost - ~accessibility_identifier:"button.graph-unlock.cancel" - ~on_press:(press send Model.CancelGraphUnlock) - ~text:(View_base.tr (Signal.sample model_source) "Cancel") []; - button ~variant:`primary - ~accessibility_identifier:"button.graph-unlock" - ~disabled_signal: - (Signal.map View_base.graph_unlock_disabled_ - model_source) - ~on_press:(press send Model.SubmitGraphPassword) - ~text:(View_base.tr (Signal.sample model_source) "Unlock") []; - ]; + scroll ~grow:1.0 [ + column ~gap:20 ~cross:`stretch ~padding:24 ~style_class:"form" + ~accessibility_identifier:"form.graph-unlock" + [ + secure_field + ~text_signal: + (Signal.map View_base.model_graph_password model_source) + ~placeholder:(View_base.tr (Signal.sample model_source) "E2EE password") ~label:(View_base.tr (Signal.sample model_source) "E2EE password") + ~accessibility_identifier:"field.graph-password" + ~on_input: + (on_input send (fun text -> + Model.ChangeGraphPassword text)) + []; + text ~style_class:"footnote" ~foreground:"muted-foreground" + ~value:(View_base.tr (Signal.sample model_source) "Enter your E2EE password to unlock this graph.") []; + ]; + ]; + row ~main:`space_between ~cross:`center ~padding_horizontal:24 ~padding_vertical:16 [ + spacer ~grow:1.0 []; + toolbar ~orientation:`horizontal ~label:(View_base.tr (Signal.sample model_source) "Graph unlock actions") + ~accessibility_identifier:"toolbar.graph-unlock" + [ + button ~variant:`ghost + ~accessibility_identifier:"button.graph-unlock.cancel" + ~on_press:(press send Model.CancelGraphUnlock) + ~text:(View_base.tr (Signal.sample model_source) "Cancel") []; + button ~variant:`primary + ~accessibility_identifier:"button.graph-unlock" + ~disabled_signal: + (Signal.map View_base.graph_unlock_disabled_ + model_source) + ~on_press:(press send Model.SubmitGraphPassword) + ~text:(View_base.tr (Signal.sample model_source) "Unlock") []; + ]; + ]; ]; ] else diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index b10e46ef..3dfeffdc 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -1144,15 +1144,9 @@ let outliner_editor_toolbar (context : Lui_ui.ui_context) model_source send "button.outliner.editor.audio" "audio" send; toolbar_button model_source (`app "toolbar-attachment") "Upload asset" "button.outliner.editor.attachment" "attachment" send; - button ~variant:`ghost ~size:`icon ~width:48 ~height:48 - ~label:(View_base.tr (Signal.sample model_source) "Page reference") - ~accessibility_identifier: - "button.outliner.editor.pageReference" - ~on_press:(fun _ -> - ignore - (send - (Model.PerformOutlinerToolbarAction "pageReference"))) - ~text:(View_base.tr (Signal.sample model_source) "[[]]") []; + toolbar_button model_source (`app "toolbar-page-reference") + (View_base.tr (Signal.sample model_source) "Page reference") + "button.outliner.editor.pageReference" "pageReference" send; ] ]; toolbar_button model_source (`app "toolbar-hide-keyboard") "Hide keyboard" "button.outliner.editor.hideKeyboard" "hideKeyboard" send; diff --git a/shared/src/logseq/view_sidebar.ml b/shared/src/logseq/view_sidebar.ml index 5fe74289..3dc0c38b 100644 --- a/shared/src/logseq/view_sidebar.ml +++ b/shared/src/logseq/view_sidebar.ml @@ -12,6 +12,8 @@ let sidebar_page_row (context : Lui_ui.ui_context) model_source let item custom = View_base.with_label_signal (Signal.map View_base.sidebar_page_title page_source) (list_item ~corner_radius:10 + ?height:(if Lui_ui.host context = KotlinHost then Some 48 else None) + ?padding_vertical:(if Lui_ui.host context = KotlinHost then Some 0 else None) ~text_signal:(Signal.map (fun page -> if custom then "" else View_base.sidebar_page_title page) page_source) ?role:(if Lui_ui.host context = KotlinHost then None else Some `navigation) ?icon:(if custom then None else Some (`app "document")) diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 099116b9..f8ef7258 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -9117,15 +9117,26 @@ let page_delete_dialog_exposes_stable_material_actions () = let zoomed_journal_block_has_a_block_delete_action () = let app = App.create (kotlin_backend ()) in let uuid = "journal-child" and journal = "journal-page" in + let page_route = node_projection journal journal "Journal" [] [] in + let block_route = node_projection uuid journal "Journal entry" [] [] in let snapshot = { (empty_core_projection ()) with selected_graph_id = Some "work"; applied_server_t = Some 42; - node_routes = [node_projection uuid journal "Journal entry" [] []] } in + sidebar = { (empty_sidebar_projection ()) with selected_page = Some { uuid = journal; title = "Journal" } }; + node_routes = [page_route] } in start app; - send app (Model.RequestAppNode uuid); + send app (Model.RequestAppNode journal); send app (Model.ApplyCoreSnapshot snapshot); flush app; let runtime = Lui_app.runtime app in let find = descendant_with_identifier runtime (Lui_app.root_node app) in + check ~msg:"the journal has a page delete action before opening the block" + (find "button.page.delete" <> -1); + send app (Model.RequestAppNode uuid); + flush app; + check_eq ~msg:"a pending block route cannot delete the journal" + (find "button.page.delete") (-1); + send app (Model.ApplyCoreSnapshot { snapshot with node_routes = [page_route; block_route] }); + flush app; let delete = find "button.block.delete" in check ~msg:"the zoomed block has a delete action" (delete <> -1); check_eq ~msg:"the journal page is never the target of a zoomed block action" @@ -9140,11 +9151,11 @@ let zoomed_journal_block_has_a_block_delete_action () = check_eq cursor 42; Some id | _ -> None) (App.model app).pending_effects |> Option.get in - check_eq (App.model app).app_navigation_path [Model.NodeRoute uuid]; + check_eq (App.model app).app_navigation_path [Model.NodeRoute journal; Model.NodeRoute uuid]; send app (Model.DequeueEffect delete_id); send app (Model.ResolveEffect (delete_id, false, "Stale cursor")); flush app; check_eq ~msg:"a rejected deletion leaves the block open" - (App.model app).app_navigation_path [Model.NodeRoute uuid]; + (App.model app).app_navigation_path [Model.NodeRoute journal; Model.NodeRoute uuid]; dispatch app (Lui_protocol.Press (find "button.block.delete")); flush app; dispatch app (Lui_protocol.Press (find "button.page-delete.confirm")); flush app; let retry_id = List.find_map (function @@ -9153,7 +9164,7 @@ let zoomed_journal_block_has_a_block_delete_action () = send app (Model.DequeueEffect retry_id); send app (Model.ResolveEffect (retry_id, true, "")); flush app; check_eq ~msg:"a successful block deletion returns to the journal" - (App.model app).app_navigation_path [] + (App.model app).app_navigation_path [Model.NodeRoute journal] let connection_menu_matches_active_page_actions () = @@ -9789,12 +9800,12 @@ let kotlin_outliner_editor_toolbar_uses_a_material_bottom_surface () = "icon") buttons; let page_reference = List.nth buttons 9 in - check_eq ~msg:"page reference does not use an unrelated code glyph" + check_eq ~msg:"page reference uses its native bracket icon" (property_string renderer page_reference Lui_protocol.InlineIconName) - ""; - check_eq ~msg:"page reference matches the iOS toolbar symbol" + "app:toolbar-page-reference"; + check_eq ~msg:"page reference renders as an icon button" (property_string renderer page_reference Lui_protocol.TextValue) - "[[]]"; + ""; dispatch application (Lui_protocol.Press (List.nth buttons 10)); flush application; check ~msg:"Android Hide keyboard crosses the typed core boundary" diff --git a/version.xcconfig b/version.xcconfig index f6b8e950..15cebc65 100644 --- a/version.xcconfig +++ b/version.xcconfig @@ -1 +1 @@ -MARKETING_VERSION = 2.0.2 +MARKETING_VERSION = 2.0.3