diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt index 527b7fb6..1a48d976 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidFeedbackRecordingTest.kt @@ -69,10 +69,12 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { val effect = envelope.optJSONObject("effect") ?: return envelope.optString("patch").takeIf { it.isNotEmpty() }?.let { patch -> ui.runOnIdle { filter.applyJson(patch) } } val kind = effect.optString("kind") + var message = "" if (kind !in setOf("refresh-graphs", "persist-ui-session", "persist-composer-draft", "save-settings", "present-attachment")) { - apply(request("executeEffect", effect.toString())) + val response = request("executeEffect", effect.toString()) + if (kind == "maintain-search-index") message = response else apply(response) } - ui.runOnIdle { bridge.resolveEffect(effect.getLong("id"), true, "").takeIf { it.isNotEmpty() }?.let(filter::applyJson) } + ui.runOnIdle { bridge.resolveEffect(effect.getLong("id"), true, message).takeIf { it.isNotEmpty() }?.let(filter::applyJson) } } error("The native effect queue did not drain") } @@ -105,7 +107,9 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { val selected = request("selectPage", uuid(page)) selectedSnapshot = selected val root = bridge.initialize(authenticationCode = 3) - apply(opened); apply(selected); host("graph-loading", "false") + apply(opened); apply(selected) + host("local-graph-ids", """["feedback"]""") + host("graph-loading", "false") ui.setContent { MaterialExpressiveTheme(colorScheme = LogseqTheme.lightScheme, typography = LogseqTheme.typography) { Box(Modifier.fillMaxSize()) { backend.Content(root) } } } @@ -212,6 +216,44 @@ class AndroidFeedbackRecordingTest(private val scenario: String) { ui.onNodeWithTag("button.outliner.code-language.option.python").performClick(); pause() } } + "calculator-live" -> { + mount(2) + val configured = request("nodeProperty", JSONObject().put("action", "setCodeLanguage") + .put("blockUuid", uuid(12)).put("value", "calc").toString()) + assertTrue(configured, JSONObject(configured).optBoolean("ok")) + apply(configured) + pause() + val content = ui.onNodeWithTag("outliner.code.content." + uuid(12), useUnmergedTree = true) + content.assertExists(ui.onRoot(useUnmergedTree = true).printToString()) + content.assertIsDisplayed().performClick() + pause() + val editor = ui.onNodeWithTag("field.outliner.block." + uuid(12), useUnmergedTree = true) + editor.assertExists(ui.onRoot(useUnmergedTree = true).printToString()) + val results = ui.onNodeWithTag("outliner.calculator.results." + uuid(12), useUnmergedTree = true) + editor.performTextReplacement("6*7\nlast*3") + ui.waitUntil(10_000) { + drain() + runCatching { results.assertTextEquals("42\n126") }.isSuccess + } + val inputBounds = editor.getUnclippedBoundsInRoot() + val outputBounds = results.getUnclippedBoundsInRoot() + assertTrue("Calculator results occupy the right column", outputBounds.left >= inputBounds.right) + editor.performImeAction() + ui.waitUntil(10_000) { + drain() + runCatching { editor.assertDoesNotExist() }.isSuccess + } + val after = JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows") + val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length() + assertEquals("Enter creates exactly one new block", beforeCount + 1, after.length()) + results.assertTextEquals("42\n126") + content.performTouchInput { longClick() } + ui.waitUntil(10_000) { + drain() + runCatching { ui.onNodeWithTag("button.outliner.selection.copy").assertIsDisplayed() }.isSuccess + } + pause() + } "language" -> { mount(1); host("settings", """{"appearance":"system","language":"system","systemLanguage":"zh-CN","spellCheck":true,"autoCorrection":true,"sidebarTabs":[],"baseURL":"http://127.0.0.1:1","version":"QA","revision":"feedback"}""") ui.onNodeWithTag("button.sidebar").performClick(); pause() diff --git a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidRichContentTest.kt b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidRichContentTest.kt index bb3b9942..3210e4a6 100644 --- a/android/app/src/androidTest/kotlin/com/logseq/app/AndroidRichContentTest.kt +++ b/android/app/src/androidTest/kotlin/com/logseq/app/AndroidRichContentTest.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.test.click import androidx.compose.ui.test.longClick import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput @@ -106,6 +108,21 @@ class AndroidRichContentTest { @Test fun quoteLongPressSelectsInsteadOfEditing() = assertLongPressSelects("""[{"type":"quote","children":[{"type":"text","text":"Quote block"}]}]""", "Quote block") + @Test fun codeLongPressSelectsInsteadOfEditing() = + assertLongPressSelects("""[{"type":"codeBlock","style":"python","text":"print(42)"}]""", "print(42)") + + @Test fun mathLongPressSelectsInsteadOfEditing() { + render("""[{"type":"math","style":"display","text":"x^2"}]""") + ui.waitUntil(10_000) { + ui.onAllNodesWithContentDescription("x^2").fetchSemanticsNodes().isNotEmpty() + } + ui.onNodeWithContentDescription("x^2").performTouchInput { longClick() } + ui.runOnIdle { + assertEquals("A formula long press selects the block exactly once", listOf("drag-start"), + events.filterIsInstance().map { it.name }) + } + } + @Test fun malformedBodyLongPressStillSelects() = assertLongPressSelects("not-json", "Readable fallback") @Test fun shortBodyTapStillEditsExactlyOnce() { diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/AndroidCodeBlock.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/AndroidCodeBlock.kt index 7ba0cb07..0f2c71c4 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/AndroidCodeBlock.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/AndroidCodeBlock.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -36,6 +37,8 @@ import dev.snipme.highlights.model.SyntaxThemes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +internal val LocalShowCodeLanguage = staticCompositionLocalOf { true } + private data class CodeKey(val code: String, val language: String, val dark: Boolean, val background: Int) private val codeCache = object : LruCache(512 * 1024) { override fun sizeOf(key: CodeKey, value: AnnotatedString) = @@ -82,11 +85,11 @@ internal fun AndroidCodeBlock(code: String, language: String) { } Surface( shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = if (LocalShowCodeLanguage.current) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent, modifier = Modifier.fillMaxWidth().testTag("block.rich.code"), ) { Column(Modifier.padding(12.dp)) { - if (language.isNotBlank()) { + if (LocalShowCodeLanguage.current && language.isNotBlank()) { Text(language, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 6.dp)) } diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt index db9d0eaa..31923562 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/LogseqExtensions.kt @@ -69,7 +69,7 @@ private const val RICH_FINGERPRINT = "ios/swiftui|standard-children:0|children:|" + "properties:10:asset-type:string:required:none,10:local-path:string:" + "required:none,11:markup-json:string:required:none,12:is-completed:bool:" + - "required:none,18:youtube-target-url:string:required:none,5:title:string:" + + "required:none,18:show-code-language:bool:optional:none,18:youtube-target-url:string:required:none,5:title:string:" + "required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|" + "events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required," + "9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[" + @@ -161,6 +161,7 @@ fun logseqExtensionRegistry( LuiExtensionProperty("local-path", LuiExtensionValueKind.STRING), LuiExtensionProperty("markup-json", LuiExtensionValueKind.STRING), LuiExtensionProperty("is-completed", LuiExtensionValueKind.BOOLEAN), + LuiExtensionProperty("show-code-language", LuiExtensionValueKind.BOOLEAN, required = false), LuiExtensionProperty("youtube-target-url", LuiExtensionValueKind.STRING), LuiExtensionProperty("title", LuiExtensionValueKind.STRING), LuiExtensionProperty("block-id", LuiExtensionValueKind.STRING), diff --git a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt index c3f0c2ef..25f8907a 100644 --- a/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt +++ b/android/app/src/main/kotlin/com/logseq/app/ui/extensions/OutlinerBlockContent.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -223,14 +224,16 @@ internal fun OutlinerBlockContent( }, ) } else { - ProjectedMarkup( - youtubeStarts = youtubeStarts, - encoded = context.string("markup-json") ?: "", - fallback = title, - completedDecoration = decoration, - onOpenNode = { context.emit("open-node", "uuid" to it) }, - onTapFallback = { context.emit("edit", "uuid" to uuid) }, - ) + CompositionLocalProvider(LocalShowCodeLanguage provides (context.bool("show-code-language") ?: true)) { + ProjectedMarkup( + youtubeStarts = youtubeStarts, + encoded = context.string("markup-json") ?: "", + fallback = title, + completedDecoration = decoration, + onOpenNode = { context.emit("open-node", "uuid" to it) }, + onTapFallback = { context.emit("edit", "uuid" to uuid) }, + ) + } } } if (dragHover) { diff --git a/apple/Sources/Logseq/LGRichBlockExtension.swift b/apple/Sources/Logseq/LGRichBlockExtension.swift index 0463a049..f65f2477 100644 --- a/apple/Sources/Logseq/LGRichBlockExtension.swift +++ b/apple/Sources/Logseq/LGRichBlockExtension.swift @@ -101,7 +101,7 @@ struct OutlinerRowDropDelegate: DropDelegate { @MainActor private enum LGRichBlockExtension { static let identifier = "outliner-block-content" - static let fingerprint = "lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]" + static let fingerprint = "lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:show-code-language:bool:optional:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]" static func register( in registry: LUIAppleExtensionRegistry, @@ -118,6 +118,7 @@ private enum LGRichBlockExtension { .init(name: "youtube-target-url", kind: .string, isRequired: true), .init(name: "is-asset", kind: .bool, isRequired: true), .init(name: "is-completed", kind: .bool, isRequired: true), + .init(name: "show-code-language", kind: .bool, isRequired: false), .init(name: "asset-type", kind: .string, isRequired: true), .init(name: "local-path", kind: .string, isRequired: true), ], @@ -199,6 +200,10 @@ private struct LGRichBlock: View { } ) .padding(.vertical, titleVerticalInset) + .environment(\.lgShowCodeLanguage, { + if case let .bool(value) = context.property("show-code-language") { return value } + return true + }()) } } .font(propertyValue ? .callout : .body) diff --git a/apple/Sources/Logseq/OutlinerRichBlockContent.swift b/apple/Sources/Logseq/OutlinerRichBlockContent.swift index 33766807..cdfa404c 100644 --- a/apple/Sources/Logseq/OutlinerRichBlockContent.swift +++ b/apple/Sources/Logseq/OutlinerRichBlockContent.swift @@ -366,7 +366,19 @@ private struct InteractiveCloze: View { } } +private struct ShowCodeLanguageKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var lgShowCodeLanguage: Bool { + get { self[ShowCodeLanguageKey.self] } + set { self[ShowCodeLanguageKey.self] = newValue } + } +} + private struct SyntaxHighlightedCodeBlock: View { + @Environment(\.lgShowCodeLanguage) private var showLanguage let code: String let language: String? @@ -377,7 +389,7 @@ private struct SyntaxHighlightedCodeBlock: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - if let language, !language.isEmpty { + if showLanguage, let language, !language.isEmpty { Text(verbatim: language) .font(.caption2.monospaced()) .foregroundStyle(.secondary) @@ -391,7 +403,7 @@ private struct SyntaxHighlightedCodeBlock: View { } } .padding(10) - .background(Color.secondary.opacity(0.1)) + .background(showLanguage ? Color.secondary.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 8)) } } diff --git a/apple/Sources/Logseq/OutlinerRowExtension.swift b/apple/Sources/Logseq/OutlinerRowExtension.swift index 17be11d9..69308a40 100644 --- a/apple/Sources/Logseq/OutlinerRowExtension.swift +++ b/apple/Sources/Logseq/OutlinerRowExtension.swift @@ -53,6 +53,8 @@ struct OutlinerRowPayload: Decodable, Equatable { let youtubeURL: String let completed: Bool let selected: Bool + let editing: Bool? + let hasCustomContent: Bool? let opensAsPage: Bool let nodeIcon: NodeIconPayload? let listNumber: Int? @@ -123,13 +125,18 @@ struct OutlinerRowPayload: Decodable, Equatable { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL + private var hasCustomContent: Bool { + row.hasCustomContent ?? !context.childIDs.isEmpty + } + @ViewBuilder var body: some View { #if os(iOS) - if context.childIDs.isEmpty { + if !(row.editing ?? !context.childIDs.isEmpty) { content .overlay { OutlinerDropIndicator(placement: dropGeometry.placement, depth: row.depth) } .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { dropGeometry.height = $0 } - .onLongPressGesture { emit("long-press") } + .contentShape(Rectangle()) + .highPriorityGesture(LongPressGesture().onEnded { _ in emit("long-press") }) .onDrag { emit("drag-start"); return NSItemProvider(object: row.uuid as NSString) } .onDrop(of: [UTType.plainText], delegate: OutlinerRowDropDelegate(geometry: dropGeometry, depth: row.depth) { emit("drop", extra: ["placement": .string($0.eventValue)]) @@ -144,7 +151,7 @@ struct OutlinerRowPayload: Decodable, Equatable { @ViewBuilder private var content: some View { Group { #if os(iOS) - if context.childIDs.isEmpty && OutlinerTextRow.supports(row) { + if !hasCustomContent && OutlinerTextRow.supports(row) { OutlinerTextRow(row: row, emit: { name, uuid, values in emit(name, uuid: uuid, extra: values) }, onOpenLink: { url in @@ -185,6 +192,9 @@ struct OutlinerRowPayload: Decodable, Equatable { .accessibilityIdentifier("button.outliner.zoom." + row.uuid + "." + row.title) } } + .simultaneousGesture(LongPressGesture().onEnded { _ in + if row.editing == true { emit("long-press") } + }) .padding(.trailing, row.status != nil ? OutlinerLayoutMetrics.statusLeading - OutlinerLayoutMetrics.bulletHitSize : (row.priority != nil ? -4 : OutlinerLayoutMetrics.bulletContentSpacing)) @@ -232,8 +242,9 @@ struct OutlinerRowPayload: Decodable, Equatable { .accessibilityIdentifier("button.block-task-priority") } VStack(alignment: .leading, spacing: 2) { - if let editor = context.childIDs.first { + if hasCustomContent, let editor = context.childIDs.first { context.content(for: editor) + .font(row.markup.contains { $0.type == .codeBlock } ? .caption : .body) } else if row.isAsset { LGAssetPreview(uuid: row.uuid, title: row.title, assetType: row.assetType, localPath: row.localPath) } else { diff --git a/apple/Tests/LogseqTests/LGRendererTests.swift b/apple/Tests/LogseqTests/LGRendererTests.swift index 510d7c87..37d09750 100644 --- a/apple/Tests/LogseqTests/LGRendererTests.swift +++ b/apple/Tests/LogseqTests/LGRendererTests.swift @@ -85,7 +85,7 @@ struct LGRendererTests { try renderer.apply(patchJSON: """ {"generation":1,"ops":[ {"op":"create-node","id":1,"kind":"root"}, - {"op":"create-extension","id":2,"identifier":"outliner-block-content","fingerprint":"lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]"}, + {"op":"create-extension","id":2,"identifier":"outliner-block-content","fingerprint":"lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:show-code-language:bool:optional:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]"}, {"op":"set-extension-prop","id":2,"property":"block-id","value":"block-a"}, {"op":"set-extension-prop","id":2,"property":"title","value":"Project"}, {"op":"set-extension-prop","id":2,"property":"markup-json","value":"[]"}, diff --git a/apple/Tests/UI/outliner-return-handoff.swift b/apple/Tests/UI/outliner-return-handoff.swift index 36a1e2ed..37e31cdc 100644 --- a/apple/Tests/UI/outliner-return-handoff.swift +++ b/apple/Tests/UI/outliner-return-handoff.swift @@ -5,6 +5,7 @@ import SwiftUI final class EditorModel: ObservableObject { @Published var title = "First block" @Published var blockID = "original" + @Published var inputPolicy = "{}" @Published var caret = "First block".utf16.count var returns = 0 var pendingCarets: [Int]? @@ -35,7 +36,7 @@ private struct EditorHost: View { HStack(alignment: .top, spacing: 2) { BulletMarker().frame(width: 24, height: 24) OutlinerInlineEditor(text: model.title, blockID: model.blockID, - desiredCaretUTF16Offset: model.caret, + desiredCaretUTF16Offset: model.caret, inputPolicy: model.inputPolicy, onTextChange: { model.title = $0; model.caret = $1 }, onReturn: model.enter, onBackspace: { _, _ in }, @@ -115,6 +116,27 @@ struct ReturnHandoffCheck { if model.returns != beforeRapid + 1 || model.title != "\nAfter" || editor().selectedRange.location != 6 { print("FAIL: rapid Enter, Shift+Enter, and typing must preserve the new block text; returns=\(model.returns) expected=\(beforeRapid + 1) title=\(model.title.debugDescription) caret=\(editor().selectedRange.location)"); failures += 1 } + for code in [true, false] { + model.inputPolicy = "{\"multiline\":false,\"code\":\(code)}" + model.title = "x^2"; model.blockID = code ? "code" : "math"; model.caret = 3 + await settle() + let beforeReturn = model.returns + type("\n", in: editor()) + await settle() + if model.returns != beforeReturn + 1 || !model.title.isEmpty { + print("FAIL: Enter in Code and Math must create a new block"); failures += 1 + } + model.title = "πŸ™‚alpha beta"; model.blockID += "-newline"; model.caret = 12 + await settle() + editor().selectedRange = NSRange(location: 2, length: 5) + let beforeNewline = model.returns + shiftEnter() + await settle() + if model.returns != beforeNewline || model.title != "πŸ™‚\n beta" || editor().selectedRange.location != 3 { + print("FAIL: Shift+Enter in Code and Math must replace the selected text with a newline"); failures += 1 + } + } + model.inputPolicy = "{}" model.title = "Matrix parent 20261018"; model.blockID = "delayed-selection"; model.caret = model.title.utf16.count await settle() model.pendingCarets = [] diff --git a/apple/Tests/UI/special-block-presentation.swift b/apple/Tests/UI/special-block-presentation.swift new file mode 100644 index 00000000..eff2d899 --- /dev/null +++ b/apple/Tests/UI/special-block-presentation.swift @@ -0,0 +1,94 @@ +import SwiftUI +import UIKit +import WebKit +@testable import Logseq +@testable import LUIAppleBackend + +@MainActor private final class SpecialBlockEffects: LGEffectExecuting { + func execute(_ effect: LGEffect) async -> LGEffectResolution { + .init(succeeded: true, message: "", output: .discard) + } +} + +@main struct SpecialBlockPresentationCheck { + @MainActor static func main() async throws { + func snapshot(editing: String? = nil) throws -> String { + var rows: [[String: Any]] = [ + ["depth": 0, "block": ["uuid": "text", "pageId": "page", "title": "Ordinary text remains visible", + "markup": [["type": "text", "text": "Ordinary text remains visible"]]]], + ["depth": 0, "block": ["uuid": "math", "pageId": "page", "title": "x^2", "displayType": "math", + "markup": [["type": "math", "style": "display", "text": "x^2"]]]], + ["depth": 0, "block": ["uuid": "code", "pageId": "page", "title": "let x = 42", "displayType": "code", + "markup": [["type": "codeBlock", "style": "ocaml", "text": "let x = 42"]]]], + ] + if editing == "next" { + rows.append(["depth": 0, "block": ["uuid": "next", "pageId": "page", "title": "", "markup": []]]) + } + var state: [String: Any] = [:] + if let editing { + let title = editing == "code" ? "let x = 42" : (editing == "next" ? "" : "x^2") + state["editing"] = ["uuid": editing, "title": title, "caretUTF16Offset": title.utf16.count] + } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": [ + "selectedGraphId": "demo", "graphName": "Special block regression", + "selectedPage": ["uuid": "page", "title": "Code and math"], + "outlinerRows": rows, "outlinerState": state]]), as: UTF8.self) + } + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: SpecialBlockEffects()) + try runtime.applyCoreResponse(snapshot()) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + let window = UIWindow(windowScene: UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { runtime.stop(); window.isHidden = true; window.rootViewController = nil } + func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } + func wait(_ message: String, until condition: () -> Bool) async throws { + for _ in 0..<100 { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw NSError(domain: "SpecialBlockPresentation", code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } + for editing in [nil, "math", nil] as [String?] { + try runtime.applyCoreResponse(snapshot(editing: editing)) + try await wait("Ordinary text must remain visible beside code and math") { + views(window).compactMap { $0 as? UILabel }.contains { $0.text == "Ordinary text remains visible" || $0.attributedText?.string == "Ordinary text remains visible" } + } + if editing == nil { + try await wait("A math preview must mount its formula renderer rather than an empty editor placeholder") { + views(window).contains { $0 is WKWebView && $0.bounds.width > 100 && $0.bounds.height > 0 } + } + var rendered = false + for _ in 0..<100 { + if let web = views(window).compactMap({ $0 as? WKWebView }).first, + let count = try? await web.evaluateJavaScript("document.querySelectorAll('.katex').length") as? Int, + count == 1 { + rendered = true + break + } + try await Task.sleep(for: .milliseconds(50)) + } + guard rendered else { + throw NSError(domain: "SpecialBlockPresentation", code: 2, + userInfo: [NSLocalizedDescriptionKey: "A mounted math preview must render its formula"]) + } + } else { + try await wait("Tapping a math block must still mount its native editor") { + views(window).compactMap { $0 as? UITextView }.contains { $0.accessibilityIdentifier == "field.outliner.block.math" } + } + } + } + for editing in ["code", "next"] { + try runtime.applyCoreResponse(snapshot(editing: editing)) + try await wait("Changing from a code editor to a new plain block must transfer keyboard focus") { + views(window).compactMap { $0 as? UITextView }.contains { + $0.accessibilityIdentifier == "field.outliner.block." + editing && $0.isFirstResponder + && $0.bounds.width > 100 && $0.bounds.height > 0 + } + } + } + print("PASS: ordinary text, code and math previews survive editor mounting and removal") + } +} diff --git a/scripts/test-ios-native-interactions.py b/scripts/test-ios-native-interactions.py index fe35a517..426e415b 100644 --- a/scripts/test-ios-native-interactions.py +++ b/scripts/test-ios-native-interactions.py @@ -13,6 +13,7 @@ ("outliner-return-handoff.swift", "ReturnHandoffCheck"), ("outliner-backspace-boundary.swift", "OutlinerBackspaceBoundaryCheck"), ("outliner-subtree-selection.swift", "OutlinerSubtreeSelectionCheck"), + ("special-block-presentation.swift", "SpecialBlockPresentationCheck"), ("outliner-autosave.swift", "Check")]: source = directory / filename source.write_text("@testable import Logseq\n" + (root / "apple/Tests/UI" / filename).read_text()) diff --git a/shared/src/logseq/core/node_presentation.ml b/shared/src/logseq/core/node_presentation.ml index 20812483..eaa1f156 100644 --- a/shared/src/logseq/core/node_presentation.ml +++ b/shared/src/logseq/core/node_presentation.ml @@ -2,7 +2,7 @@ module Ds = Datascript module Read = Graph_read module M = Markup -let calculator source = +let calculator_results source = let vars = Hashtbl.create 8 in let tokenize line = let re = Str.regexp "[0-9]+\\.?[0-9]*\\([eE][-+]?[0-9]+\\)?\\|[A-Za-z_][A-Za-z_0-9]*\\|[+*/^()=-]" in @@ -40,15 +40,19 @@ let calculator source = String.split_on_char '\n' source |> List.map (fun line -> let expression_source = match String.index_opt line '#' with | Some index -> String.sub line 0 index | None -> line in - if String.trim expression_source = "" then line else try + if String.trim expression_source = "" then "" else try let s = tokenize expression_source in let name = match !s with name :: "=" :: rest -> s := rest; Some name | _ -> None in let n = expression s in if !s <> [] || not (Float.is_finite n) then failwith "Invalid expression"; Option.iter (fun name -> Hashtbl.replace vars name n) name; Hashtbl.replace vars "last" n; - line ^ " = " ^ Printf.sprintf "%.10g" n - with _ -> Hashtbl.remove vars "last"; line ^ " = Error") |> String.concat "\n" + Printf.sprintf "%.10g" n + with _ -> Hashtbl.remove vars "last"; "Error") + +let calculator source = + List.map2 (fun line result -> if result = "" then line else line ^ " = " ^ result) + (String.split_on_char '\n' source) (calculator_results source) |> String.concat "\n" let discussion_kind db eid = let tagged eid ident = List.exists (fun tag -> diff --git a/shared/src/logseq/core/node_presentation.mli b/shared/src/logseq/core/node_presentation.mli index 216e178b..0d039bec 100644 --- a/shared/src/logseq/core/node_presentation.mli +++ b/shared/src/logseq/core/node_presentation.mli @@ -1 +1,2 @@ +val calculator_results : string -> string list val decorate : Datascript.db -> string -> (Yojson.Basic.t -> Yojson.Basic.t) diff --git a/shared/src/logseq/core/outliner_state.ml b/shared/src/logseq/core/outliner_state.ml index 2496c3ba..930bcb33 100644 --- a/shared/src/logseq/core/outliner_state.ml +++ b/shared/src/logseq/core/outliner_state.ml @@ -1442,8 +1442,7 @@ let enter context state editing = let block = find_block context editing.uuid in let property_value = Option.is_some block && not (List.exists (fun (block : Model.block) -> block.uuid = editing.uuid) context.blocks) in - if property_value || Option.fold ~none:false - ~some:(fun block -> List.mem block.Model.display_type [Some "code"; Some "math"]) block then + if property_value then let index = byte_index_of_utf16 editing.title editing.caret in step {state with editing = Some {editing with title = replace_range editing.title index index "\n"; caret = editing.caret + 1}} [] diff --git a/shared/src/logseq/view.ml b/shared/src/logseq/view.ml index 56555274..a8f13d47 100644 --- a/shared/src/logseq/view.ml +++ b/shared/src/logseq/view.ml @@ -9,6 +9,7 @@ let logseq_theme_tokens : (string * Lui_ui.theme_token_value) list = [ ("background", Adaptive { light = "#FCFCFC"; dark = "#002D38" }); ("surface", Adaptive { light = "#F8F8F8"; dark = "#19394D" }); + ("code-container", Adaptive { light = "#EFEFF0"; dark = "#19394D" }); ("card", Adaptive { light = "#FFFFFF"; dark = "#1C4556" }); ("link", Adaptive { light = "#1B6898"; dark = "#9BD3D4" }); ( "autocomplete-row-background", @@ -89,6 +90,7 @@ let outliner_block_content_schema () = Lui_extension.property "youtube-target-url" StringScalar true None; Lui_extension.property "is-asset" BoolScalar true None; Lui_extension.property "is-completed" BoolScalar true None; + Lui_extension.property "show-code-language" BoolScalar false None; Lui_extension.property "asset-type" StringScalar true None; Lui_extension.property "local-path" StringScalar true None; ] diff --git a/shared/src/logseq/view_outliner.ml b/shared/src/logseq/view_outliner.ml index 490ce02b..a579b2d8 100644 --- a/shared/src/logseq/view_outliner.ml +++ b/shared/src/logseq/view_outliner.ml @@ -10,9 +10,9 @@ let outliner_editor_view model_source block_id_source title_source caret_source (Signal.map2 (fun (current : Model.logseq_model) uuid -> let display_type = List.find_map (fun (row : Model.outline_row) -> if row.row_uuid = uuid then row.row_display_type else None) current.outliner_rows in - let multiline = List.mem display_type [Some "code"; Some "math"] in - StringValue (if multiline then Yojson.Basic.to_string (`Assoc [ - "multiline", `Bool true; "code", `Bool (display_type = Some "code"); + let special = List.mem display_type [Some "code"; Some "math"] in + StringValue (if special then Yojson.Basic.to_string (`Assoc [ + "multiline", `Bool false; "code", `Bool (display_type = Some "code"); "emptyPairs", `List [`Assoc ["text", `String "[[]]"; "deleteOffset", `Int 1]]]) else Outliner_state.native_input_policy)) model_source block_id_source); Lui_ui.extension_property_signal context node "focus-enabled" @@ -35,12 +35,13 @@ let outliner_editor_view model_source block_id_source title_source caret_source block_id_source send)); node -let outliner_rich_block_view model_source title_source markup_source +let outliner_rich_block_view ?(show_code_language = true) model_source title_source markup_source youtube_target_source is_asset_source row_source asset_type_source local_path_source send : t = fun context parent -> let node = Lui_ui.extension context "outliner-block-content" in attach context parent node; + Lui_ui.extension_property context node "show-code-language" (BoolValue show_code_language); Lui_ui.extension_property_signal context node "block-id" (Signal.map View_base.outliner_row_id_wire_value row_source); Lui_ui.extension_property_signal context node "title" @@ -343,6 +344,71 @@ let outliner_priority_control model_source row_source send : t = ~accessibility_identifier:"button.block-task-priority-clear" ~on_press:(fun _ -> choose "") [] ]]))] +let code_language row = + try match Yojson.Basic.from_string row.Model.markup_json with + | `List (`Assoc fields :: _) -> (match List.assoc_opt "style" fields with Some (`String language) -> language | _ -> "") + | _ -> "" with _ -> "" + +let code_language_control model_source row_source send : t = + let current_row = Signal.sample row_source in + if_ ~test:(Signal.map (fun (row : Model.outline_row) -> row.row_display_type = Some "code") row_source) + (row ~main:`start ~padding_horizontal:12 ~padding_vertical:2 + ~accessibility_identifier:("outliner.code.language-header." ^ current_row.row_uuid) [ + box ~height:28 [menu_trigger ~style_class:"caption" ~foreground:"muted-foreground" ~text_signal:(Signal.map (fun row -> let value = code_language row in if value = "" then "Plain text" else value) row_source) + ~label:(View_base.tr (Signal.sample model_source) "Code language") + ~accessibility_identifier:("button.outliner.code-language." ^ current_row.row_uuid) + [dropdown_menu (List.map (fun (value, label) -> + menu_item ~text:label ~accessibility_identifier:("button.outliner.code-language.option." ^ value) + ~on_press:(fun _ -> + let row = Signal.sample row_source in + ignore (send (Model.SubmitNodeProperty (row.row_uuid, Yojson.Basic.to_string (`Assoc [ + "action", `String "setCodeLanguage"; "blockUuid", `String row.row_uuid; "value", `String value]))))) []) + ["", "Plain text"; "bash", "Bash"; "c", "C"; "cpp", "C++"; "clojure", "Clojure"; + "css", "CSS"; "go", "Go"; "html", "HTML"; "java", "Java"; "javascript", "JavaScript"; + "json", "JSON"; "kotlin", "Kotlin"; "markdown", "Markdown"; "ocaml", "OCaml"; + "python", "Python"; "ruby", "Ruby"; "rust", "Rust"; "sql", "SQL"; "swift", "Swift"; + "typescript", "TypeScript"; "yaml", "YAML"; "calc", "Calculator"]) ] ] ]) + +let code_block_content model_source retained_source row_source send : t = + fun context parent -> + let current_row = Signal.sample row_source in + let source = Signal.map (fun retained -> if View_rows.retained_row_editing_ retained then + View_rows.retained_row_editing_title retained else (View_rows.retained_row_value retained).Model.row_title) retained_source in + let calculator = Signal.map (fun row -> code_language row = "calc") row_source in + let input = column ~gap:0 ~cross:`stretch [ + if_ ~test:(reactive View_rows.retained_row_editing_ retained_source) + (outliner_editor_view model_source (Signal.map View_base.outliner_row_uuid row_source) + (Signal.map View_rows.retained_row_editing_title retained_source) + (Signal.map View_rows.retained_row_editing_caret retained_source) send); + if_ ~test:(Signal.map (fun retained -> not (View_rows.retained_row_editing_ retained)) retained_source) + (list_item ~padding:0 + ~accessibility_identifier:("outliner.code.content." ^ current_row.row_uuid) + ~on_press:(fun _ -> ignore (send (Model.BeginOutlinerEdit (Signal.sample row_source).row_uuid))) + ~on_long_press:(fun _ -> ignore (send (Model.LongPressOutlinerBlock (Signal.sample row_source).row_uuid))) + [dyn (fun calculator -> if calculator then + text ~value_signal:source ~font_family:"monospace" ~font_size:"17" ~padding:12 [] else + outliner_rich_block_view ~show_code_language:false model_source + (Signal.map View_base.outliner_row_title row_source) + (Signal.map (fun (row : Model.outline_row) -> row.markup_json) row_source) + (Signal.map View_base.outliner_row_youtube_target row_source) + (Signal.map (fun (row : Model.outline_row) -> row.is_asset) row_source) row_source + (Signal.map View_base.outliner_row_asset_type row_source) + (Signal.map View_base.outliner_row_local_path row_source) send) calculator]); + ] in + (column ~gap:0 ~cross:`stretch ~corner_radius:8 + ~background_signal:(Signal.map (fun retained -> + if View_rows.retained_row_selected_ retained then "#00000000" else "code-container") retained_source) + ~accessibility_identifier:("outliner.code." ^ current_row.Model.row_uuid) [ + code_language_control model_source row_source send; + dyn (fun calculator -> if not calculator then input else + row ~gap:0 ~cross:`start + ~accessibility_identifier:("outliner.calculator." ^ current_row.row_uuid) [box ~grow:1.0 [input]; + box ~min_width:64 ~padding:12 [ + text ~value_signal:(Signal.map (fun source -> String.concat "\n" (Node_presentation.calculator_results source)) source) + ~font_family:"monospace" ~font_size:"17" ~foreground:"muted-foreground" + ~accessibility_identifier:("outliner.calculator.results." ^ current_row.row_uuid) []]]) calculator; + ]) context parent + let outliner_row_main_content (context : Lui_ui.ui_context) model_source retained_row_source row_source send : t = let current_row = Signal.sample row_source in @@ -402,13 +468,14 @@ let outliner_row_main_content (context : Lui_ui.ui_context) model_source column ~gap:2 ~grow:1.0 ~cross:`start ~accessibility_identifier:("outliner.title-and-tags." ^ current_row.row_uuid) [ - if_ ~test:editing_source - (outliner_editor_view model_source block_id_source editing_title_source - editing_caret_source send); - if_ ~test:not_editing_source - (outliner_rich_block_view model_source title_source - markup_source youtube_target_source is_asset_source - row_source asset_type_source local_path_source send); + dyn (fun code -> if code then code_block_content model_source retained_row_source row_source send else + column ~gap:0 ~cross:`stretch [ + if_ ~test:editing_source + (outliner_editor_view model_source block_id_source editing_title_source editing_caret_source send); + if_ ~test:not_editing_source + (outliner_rich_block_view model_source title_source markup_source youtube_target_source is_asset_source + row_source asset_type_source local_path_source send)]) + (Signal.map (fun (row : Model.outline_row) -> row.row_display_type = Some "code") row_source); if_ ~test:has_tags_source (row ~gap:6 [ @@ -466,6 +533,8 @@ let native_outliner_row model_source retained_source row_source send : t = "youtubeURL", `String (View_base.outliner_row_youtube_target row); "completed", `Bool (View_base.outliner_row_completed_ row); "selected", `Bool retained.is_selected; + "editing", `Bool (View_rows.retained_row_editing_ retained); + "hasCustomContent", `Bool (View_rows.retained_row_editing_ retained || row.row_display_type = Some "code"); "canRemoveTags", `Bool (Model.node_can_set_tags current row.row_uuid); "removeTagTitle", `String (View_base.tr current "Remove tag"); "opensAsPage", `Bool row.opens_as_page; @@ -482,10 +551,12 @@ let native_outliner_row model_source retained_source row_source send : t = "tags", `List (List.map summary row.tags)]) in Lui_ui.extension_property_signal context node "payload" (Signal.map2 (fun current retained -> StringValue (payload current retained)) model_source retained_source); - ignore ((if_ ~test:(reactive View_rows.retained_row_editing_ retained_source) - (box ~padding:0 [outliner_editor_view model_source (Signal.map View_base.outliner_row_uuid row_source) - (Signal.map View_rows.retained_row_editing_title retained_source) - (Signal.map View_rows.retained_row_editing_caret retained_source) send])) context (Some node)); + ignore ((dyn (fun code -> if code then code_block_content model_source retained_source row_source send else + if_ ~test:(reactive View_rows.retained_row_editing_ retained_source) + (box ~padding:0 [outliner_editor_view model_source (Signal.map View_base.outliner_row_uuid row_source) + (Signal.map View_rows.retained_row_editing_title retained_source) + (Signal.map View_rows.retained_row_editing_caret retained_source) send])) + (Signal.map (fun (row : Model.outline_row) -> row.row_display_type = Some "code") row_source)) context (Some node)); Lui_ui.on_event context node (function | ExtensionEvent (_, _, "remove-tag", values) -> (match String_map.find_opt "uuid" values with @@ -627,29 +698,6 @@ let row_metadata (row : Model.outline_row) = let metadata_member key row = match row_metadata row with `Assoc fields -> Option.value ~default:`Null (List.assoc_opt key fields) | _ -> `Null -let code_language_control model_source row_source send : t = - let row = Signal.sample row_source in - let language row = - try match Yojson.Basic.from_string row.Model.markup_json with - | `List (`Assoc fields :: _) -> (match List.assoc_opt "style" fields with Some (`String language) -> language | _ -> "") - | _ -> "" with _ -> "" in - if_ ~test:(Signal.map (fun (row : Model.outline_row) -> row.row_display_type = Some "code") row_source) - (box ~padding_horizontal:32 [ - menu_trigger ~text_signal:(Signal.map (fun row -> let value = language row in if value = "" then View_base.tr (Signal.sample model_source) "Plain text" else value) row_source) - ~label:(View_base.tr (Signal.sample model_source) "Code language") - ~accessibility_identifier:("button.outliner.code-language." ^ row.row_uuid) - [dropdown_menu (List.map (fun (value, label) -> - menu_item ~text:(View_base.tr (Signal.sample model_source) label) ~accessibility_identifier:("button.outliner.code-language.option." ^ value) - ~on_press:(fun _ -> - let row = Signal.sample row_source in - ignore (send (Model.SubmitNodeProperty (row.row_uuid, Yojson.Basic.to_string (`Assoc [ - "action", `String "setCodeLanguage"; "blockUuid", `String row.row_uuid; "value", `String value]))))) []) - ["", "Plain text"; "bash", "Bash"; "c", "C"; "cpp", "C++"; "clojure", "Clojure"; - "css", "CSS"; "go", "Go"; "html", "HTML"; "java", "Java"; "javascript", "JavaScript"; - "json", "JSON"; "kotlin", "Kotlin"; "markdown", "Markdown"; "ocaml", "OCaml"; - "python", "Python"; "ruby", "Ruby"; "rust", "Rust"; "sql", "SQL"; "swift", "Swift"; - "typescript", "TypeScript"; "yaml", "YAML"; "calc", "Calculator"]) ] ]) - let discussion_body model_source row_source body : t = let row = Signal.sample row_source in dyn (fun kind -> @@ -678,7 +726,7 @@ let reaction_strip row_source : t = ~background:"muted" ~style_class:"caption" [])]) let decorate_row model_source row_source send body = - column ~gap:0 [code_language_control model_source row_source send; discussion_body model_source row_source body; reaction_strip row_source] + column ~gap:0 [discussion_body model_source row_source body; reaction_strip row_source] let render_result_block context model_source row_source send = let retained = Signal.map2 View_rows.make_retained_outline_row model_source row_source in diff --git a/shared/src/logseq/view_outliner.mli b/shared/src/logseq/view_outliner.mli index 49110fdf..a3933df2 100644 --- a/shared/src/logseq/view_outliner.mli +++ b/shared/src/logseq/view_outliner.mli @@ -4,6 +4,7 @@ val outliner_editor_view : string Signal.signal -> int Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val outliner_rich_block_view : + ?show_code_language:bool -> Model.logseq_model Signal.signal -> string Signal.signal -> string Signal.signal -> diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 5354b2d5..3c6d88a5 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -2147,7 +2147,7 @@ let outliner_editor_extension_contract_is_pinned () = let outliner_block_content_extension_contract_is_pinned () = check_eq ~msg:"the rich block renderer must match the LG wire schema" (Lui_extension.fingerprint (View.outliner_block_content_schema ())) - "lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]" + "lui-extension-v1|22:outliner-block-content|profiles:android/kotlin,ios/swiftui|standard-children:0|children:|properties:10:asset-type:string:required:none,10:local-path:string:required:none,11:markup-json:string:required:none,12:is-completed:bool:required:none,18:show-code-language:bool:optional:none,18:youtube-target-url:string:required:none,5:title:string:required:none,8:block-id:string:required:none,8:is-asset:bool:required:none|events:10:drag-start[4:uuid:string:required],4:drop[4:uuid:string:required,9:placement:string:required],4:edit[4:uuid:string:required],9:open-node[4:uuid:string:required]" let native_navigation_stack_extension_contract_is_pinned () = let schema = diff --git a/shared/test/logseq/code_language_test.ml b/shared/test/logseq/code_language_test.ml index bce11d9f..4f5a8bf8 100644 --- a/shared/test/logseq/code_language_test.ml +++ b/shared/test/logseq/code_language_test.ml @@ -18,4 +18,95 @@ let code_language_can_be_changed_and_cleared () = R.with_runtime (fun session ru check_eq (member "ok" (Rpc_test.dispatch_json session "nodeProperty" (J.to_string (`Assoc [ "action", `String "setCodeLanguage"; "blockUuid", `String Q.beta; "value", `String "python"])))) (`Bool false)) -let cases = [case "code language" code_language_can_be_changed_and_cleared] +let code_commands_keep_one_block_and_its_language_control () = + List.iter (fun backend -> List.iter (fun label -> R.with_runtime (fun session runtime _ -> + let block_ids () = Graph_runtime.blocks_for_page runtime Q.page + |> List.map (fun (block : Cache_model.block) -> block.uuid) |> List.sort compare in + let before = block_ids () in + ignore (Editor_command_test.run session label); + check_eq ~msg:"A code command on an empty draft converts the existing block" (block_ids ()) before; + check_eq (Editor_command_test.value runtime Q.alpha "block/title") (Some (Ds.String "")); + check_eq (Editor_command_test.value runtime Q.alpha "logseq.property.node/display-type") (Some (Ds.Keyword "code")); + let inspect ~editing = + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + App_test.send app (Model.ChooseSettingsLanguage "zh-CN"); App_test.flush app; + let host = Lui_app.runtime app in + let owner = App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.block." ^ Q.alpha) in + if owner <> -1 && App_test.native_row app owner <> -1 then ( + let payload = App_test.native_row_payload app owner in + check_eq ~msg:"Native rows distinguish an editor from a code preview child" + (member "editing" payload) (`Bool editing)); + let panel = App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.code." ^ Q.alpha) in + check ~msg:"The language selector belongs to the code surface, rather than a separate prose row" (panel <> -1); + let language = App_test.descendant_with_identifier host panel ("button.outliner.code-language." ^ Q.alpha) in + check ~msg:"The code surface retains its language menu" (language <> -1); + check_eq ~msg:"Code mode names remain English in a Chinese app" + (App_test.property_string host language Lui_protocol.TextValue) (if label = "Calculator" then "calc" else "Plain text"); + let calculator = App_test.descendant_with_identifier host language "button.outliner.code-language.option.calc" in + check_eq ~msg:"The Calculator code mode is not localized" + (App_test.property_string host calculator Lui_protocol.TextValue) "Calculator"; + check_eq ~msg:"The language header shares a visible code container background" + (App_test.property_string host panel Lui_protocol.BackgroundValue) "code-container"; + let header = App_test.descendant_with_identifier host panel ("outliner.code.language-header." ^ Q.alpha) in + check_eq ~msg:"Code mode stays in the left corner" + (App_test.property_string host header Lui_protocol.MainAlignment) "start"; + check_eq ~msg:"The language selector is a compact caption" + (App_test.property_string host language Lui_protocol.StyleClass) "caption"; + if editing then + check ~msg:"The same code surface contains the active editor" + (App_test.descendant_with_extension host app panel "outliner-editor" <> -1) + else ( + let content = App_test.descendant_with_identifier host panel ("outliner.code.content." ^ Q.alpha) in + check ~msg:"The code surface retains a tappable rendered body" (content <> -1); + App_test.dispatch app (Lui_protocol.Press content); App_test.flush app; + check ~msg:"Tapping code reopens that block's editor" + (List.exists (function Model.TapOutlinerBlockEffect (_, uuid) -> uuid = Q.alpha | _ -> false) + (App.model app).pending_effects)); + let option = App_test.descendant_with_identifier host language "button.outliner.code-language.option.python" in + App_test.dispatch app (Lui_protocol.Press option); App_test.flush app; + check ~msg:"The embedded language menu still updates the same code block" + (List.exists (function Model.NodePropertyEffect (_, uuid, payload) -> + uuid = Q.alpha && member "value" (J.from_string payload) = `String "python" | _ -> false) + (App.model app).pending_effects)) in + inspect ~editing:true; + ignore (Editor_command_test.type_at session "print(42)" 9); + Editor_command_test.save session; + ignore (Editor_command_test.event session "cancelEditing" []); + inspect ~editing:false; + ignore (Editor_command_test.event session "longPressBlock" ["uuid", `String Q.alpha]); + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + let panel = App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.code." ^ Q.alpha) in + check_eq ~msg:"A selected code surface lets the block selection highlight show through" + (App_test.property_string host panel Lui_protocol.BackgroundValue) "#00000000"; + check ~msg:"Code and Calculator long press expose block selection actions" + (App_test.descendant_with_identifier host (Lui_app.root_node app) "button.outliner.selection.copy" <> -1)); + check_eq ~msg:"Finishing code editing does not add a language block" (block_ids ()) before)) + ["Code block"; "Calculator"]) [App_test.ios_backend; App_test.kotlin_backend] + +let calculator_results_follow_the_unsaved_editor () = + List.iter (fun backend -> R.with_runtime (fun session runtime _ -> + ignore (Editor_command_test.run session "Calculator"); + Q.with_ui backend (Rpc_session.snapshot_visible session) (fun app -> + let host = Lui_app.runtime app in + List.iter (fun (source, expected) -> + App_test.send app (Model.ChangeOutlinerText (Q.alpha, source, Outliner_state.utf16_length source)); + App_test.flush app; + let layout = App_test.descendant_with_identifier host (Lui_app.root_node app) ("outliner.calculator." ^ Q.alpha) in + check_eq ~msg:"Calculator expressions and results use a horizontal layout" + (App_test.node host layout) (Some Lui_protocol.Row); + let results = App_test.descendant_with_identifier host layout ("outliner.calculator.results." ^ Q.alpha) in + check_eq ~msg:"The right column recalculates before saving or waiting for the core" + (App_test.property_string host results Lui_protocol.TextValue) expected; + let editor = App_test.descendant_with_extension host app layout "outliner-editor" in + check_eq ~msg:"The left editor retains only the source expressions" + (App_test.extension_property app editor "title") (Some (Lui_protocol.StringValue source)); + check_eq ~msg:"A live calculation does not persist an intermediate draft" + (Editor_command_test.value runtime Q.alpha "block/title") (Some (Ds.String ""))) + ["6*7\nlast*3", "42\n126"; "a=2\n\n# comment\na*3", "2\n\n\n6"; + "2*3\ninvalid(2)\nlast*2\n4", "6\nError\nError\n4"; "", ""]))) + [App_test.ios_backend; App_test.kotlin_backend] + +let cases = [case "calculator results follow the unsaved editor" calculator_results_follow_the_unsaved_editor; + case "code language" code_language_can_be_changed_and_cleared; + case "code command keeps its language control inside the block" code_commands_keep_one_block_and_its_language_control] diff --git a/shared/test/logseq/editor_command_test.ml b/shared/test/logseq/editor_command_test.ml index b7f954a6..6f012b27 100644 --- a/shared/test/logseq/editor_command_test.ml +++ b/shared/test/logseq/editor_command_test.ml @@ -429,10 +429,11 @@ let code_commands_update_the_active_editor_style () = check ~msg:"code conversion keeps the editor mounted" (editor <> -1); let policy = match App_test.extension_property app editor "input-policy" with | Some (Lui_protocol.StringValue value) -> J.from_string value | _ -> `Null in - check_eq ~msg:"Code and Calculator commands immediately select code editor presentation" - (member "code" policy) (`Bool true); - check_eq ~msg:"the active code editor keeps multiline input" (member "multiline" policy) (`Bool true)))) - ["Code block"; "Calculator"]) [App_test.ios_backend; App_test.kotlin_backend] + check_eq ~msg:"Code, Calculator and Math commands immediately select their editor presentation" + (member "code" policy) (`Bool (label <> "Math block")); + check_eq ~msg:"Enter submits a new block while Shift+Enter remains an explicit line break" + (member "multiline" policy) (`Bool false)))) + ["Code block"; "Calculator"; "Math block"]) [App_test.ios_backend; App_test.kotlin_backend] let calculator_last_tracks_successful_lines () = R.with_runtime (fun session _ _ -> ignore (run session "Calculator"); @@ -530,16 +531,71 @@ let copied_urls_include_the_graph_identity () = R.with_runtime (fun session _ _ (List.exists (fun command -> member "type" command = `String "setClipboardText" && member "text" command = `String ("logseq://graph/plain-1?block-id=" ^ Q.alpha)) commands)) -let return_selects_completion_and_code_keeps_line_breaks () = R.with_runtime (fun session runtime _ -> - ignore (start session "/h1"); - ignore (event session "returnPressed" []); - check_eq ~msg:"Return selects the highlighted first command" (value runtime Q.alpha "logseq.property/heading") (Some (Ds.Int64 1L)); - ignore (run session "Code block"); - ignore (event session "returnPressed" ["title", `String "let x = 1"; "caretUTF16Offset", `Int 9]); - let response = snapshot session in - check_eq ~msg:"a code block keeps Return inside the same block" - (member "uuid" (draft response)) (`String Q.alpha); - check_eq (member "title" (draft response)) (`String "let x = 1\n")) +let return_selects_completion_and_special_blocks_create_siblings () = + R.with_runtime (fun session runtime _ -> + ignore (start session "/h1"); + ignore (event session "returnPressed" []); + check_eq ~msg:"Return selects the highlighted first command" (value runtime Q.alpha "logseq.property/heading") (Some (Ds.Int64 1L))); + List.iter (fun label -> List.iter (fun (title, caret, before, after) -> + R.with_runtime (fun session runtime _ -> + ignore (run session label); + let previous_ids = Graph_runtime.blocks_for_page runtime Q.page |> List.map (fun (b : Cache_model.block) -> b.uuid) in + ignore (type_at session title caret); + let response = event session "returnPressed" ["title", `String title; "caretUTF16Offset", `Int caret] in + let next = text (member "uuid" (draft response)) in + check ~msg:(label ^ " Enter focuses a new block") (next <> "" && next <> Q.alpha); + check_eq ~msg:"Enter retains the expected content in the original block" (value runtime Q.alpha "block/title") (Some (Ds.String before)); + check_eq ~msg:"The new block receives the expected content" (value runtime next "block/title") (Some (Ds.String after)); + check_eq ~msg:"The new block starts as ordinary text" (value runtime next "logseq.property.node/display-type") None; + check_eq ~msg:"Enter adds exactly one block" + (List.length (Graph_runtime.blocks_for_page runtime Q.page)) (List.length previous_ids + 1))) + ["", 0, "", ""; "x^2", 0, "x^2", ""; "x^2", 3, "x^2", ""; + "πŸ™‚A\nB", 3, "πŸ™‚A", "\nB"]) + ["Code block"; "Calculator"; "Math block"] + +let special_commands_match_master_for_click_and_enter () = + List.iter (fun (label, query, typ, language) -> + List.iter (fun (prefix, suffix, existing_type) -> + List.iter (fun via_enter -> R.with_runtime (fun session runtime conn -> + Option.iter (fun typ -> rebase runtime conn [put Q.alpha "logseq.property.node/display-type" (Ds.Keyword typ)]) existing_type; + let before = List.length (Graph_runtime.blocks_for_page runtime Q.page) in + ignore (start session (prefix ^ query ^ suffix)); + ignore (type_at session (prefix ^ query ^ suffix) (Outliner_state.utf16_length (prefix ^ query))); + let response = if via_enter then event session "returnPressed" [] else command session label in + let uuid = text (member "uuid" (draft response)) in + let remaining = prefix ^ suffix in + let inserts = typ = "code" && (String.trim remaining <> "" || existing_type <> None) in + check_eq ~msg:(label ^ " uses master conversion rules through both selection methods") (uuid <> Q.alpha) inserts; + check_eq ~msg:"The slash command and query text are removed" (member "title" (draft response)) + (`String (if inserts then "" else remaining)); + check_eq (value runtime uuid "logseq.property.node/display-type") (Some (Ds.Keyword typ)); + check_eq (value runtime uuid "logseq.property.code/lang") (Option.map (fun lang -> Ds.String lang) language); + check_eq ~msg:"Commands insert exactly one block only when master does" + (List.length (Graph_runtime.blocks_for_page runtime Q.page)) (before + if inserts then 1 else 0); + if inserts then check_eq ~msg:"Inserting code preserves the original prose" + (value runtime Q.alpha "block/title") (Some (Ds.String (String.trim remaining))))) [false; true]) + ["", "", None; "Keep this ", "", None; "", " suffix", None; + "πŸ™‚ 前文 ", " εŽζ–‡", None; "", "", Some "quote"]) + ["Code block", "/code", "code", None; "Calculator", "/calculator", "code", Some "calc"; + "Math block", "/math", "math", None] + +let every_slash_command_removes_its_typed_query () = + let entries = R.with_runtime (fun _ runtime _ -> Editor_command.catalog (Some (Graph_runtime.db runtime))) in + List.iter (fun (entry : Editor_command.entry) -> + List.iter (fun via_enter -> R.with_runtime (fun session runtime _ -> + let prefix = "πŸ™‚ 前文 " and suffix = " εŽζ–‡" in + let query = "/" ^ entry.label in + let title = prefix ^ query ^ suffix in + ignore (start session title); + ignore (type_at session title (Outliner_state.utf16_length (prefix ^ query))); + let response = if via_enter then event session "returnPressed" [] else command session entry.label in + let active = text (member "title" (draft response)) in + let original = match value runtime Q.alpha "block/title" with Some (Ds.String title) -> title | _ -> "" in + check ~msg:(entry.label ^ " clears the slash and typed query") + (not (String_kit.includes ~sub:query active) && not (String_kit.includes ~sub:query original)); + let content = match entry.action with Editor_command.Code _ -> original | _ -> active in + check ~msg:(entry.label ^ " preserves surrounding text") + (String.starts_with ~prefix content && String.ends_with ~suffix content))) [false; true]) entries let code_in_text_creates_a_separate_block () = R.with_runtime (fun session runtime _ -> ignore (start session "Keep this /"); @@ -869,7 +925,9 @@ let cases = [ case "empty journals project without writes" empty_journals_are_projected_without_writes; case "page placeholders are lazy and editable" page_placeholders_are_lazy_and_editable; case "empty journal headings stay visible" empty_journal_pages_keep_their_heading; - case "Return selects completion and code keeps line breaks" return_selects_completion_and_code_keeps_line_breaks; + case "Return selects completion and special blocks create siblings" return_selects_completion_and_special_blocks_create_siblings; + case "special command selection matches master" special_commands_match_master_for_click_and_enter; + case "every slash command removes its typed query" every_slash_command_removes_its_typed_query; case "code in prose creates a separate block" code_in_text_creates_a_separate_block; case "HTML images and underline render as content" html_images_and_underline_render_as_content; case "command output has native presentation" command_output_has_native_presentation;