From 652ba00309ca8fbf5526ccf3e7792584a56ea092 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 18 Jun 2026 22:12:31 +0800 Subject: [PATCH 001/187] fix: keep styled rich text off foreground draw V2 styled string nodes are rendered by ArkUI Text internally. Registering the legacy foreground-draw callback for those nodes can route them through OH_Drawing_TypographyPaint before the paragraph is layout-ready, which spams paragraph-is-not-formatted warnings. Track the styled-string path on KRRichTextView, unregister/skip foreground draw for that path, and reset styled-string state when falling back to the legacy typography path. --- .../expand/components/richtext/KRRichTextView.cpp | 12 +++++++++++- .../expand/components/richtext/KRRichTextView.h | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index 210453a2b..195de1904 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -108,7 +108,9 @@ void KRRichTextView::SetShadow(const std::shared_ptr &sha // shadow->StyledStringEnabled()(含 image span 时返 false),本处只读一个结果。 bool use_styled_string = textShadow && textShadow->StyledStringEnabled(); bool has_image_span = textShadow && textShadow->HasImageSpans(); + use_styled_string_ = use_styled_string; if(use_styled_string){ + KREventDispatchCenter::GetInstance().UnregisterCustomEvent(shared_from_this()); ArkUI_AttributeItem item; if(std::shared_ptr paragraph = std::dynamic_pointer_cast(shadow)->GetParagraph()){ item.object = paragraph->GetStyledString(); @@ -122,6 +124,8 @@ void KRRichTextView::SetShadow(const std::shared_ptr &sha paragraph_ = paragraph; } }else { + kuikly::util::GetNodeApi()->resetAttribute(GetNode(), NODE_TEXT_CONTENT_WITH_STYLED_STRING); + paragraph_ = nullptr; KREventDispatchCenter::GetInstance().RegisterCustomEvent(shared_from_this(), ARKUI_NODE_CUSTOM_EVENT_ON_FOREGROUND_DRAW); kuikly::util::GetNodeApi()->markDirty(GetNode(), NODE_NEED_RENDER); } @@ -144,6 +148,9 @@ void KRRichTextView::SetShadow(const std::shared_ptr &sha void KRRichTextView::DidMoveToParentView() { IKRRenderViewExport::DidMoveToParentView(); + if (use_styled_string_) { + return; + } auto self = shared_from_this(); KREventDispatchCenter::GetInstance().RegisterCustomEvent(self, ARKUI_NODE_CUSTOM_EVENT_ON_FOREGROUND_DRAW); } @@ -153,10 +160,14 @@ void KRRichTextView::DidRemoveFromParentView() { IKRRenderViewExport::DidRemoveFromParentView(); shadow_ = nullptr; paragraph_ = nullptr; + use_styled_string_ = false; last_draw_frame_width_ = -1.0; } void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { + if (use_styled_string_) { + return; + } if (shadow_ == nullptr && GetFrame().width == 0) { KR_LOG_ERROR << "OnForegroundDraw, shadow or frame not ready, shadow:" << shadow_.get() << ", frame width:" << GetFrame().width; @@ -740,4 +751,3 @@ bool KRRichTextView::UpdateSelection(std::shared_ptr ancest return has_intersection; } - diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h index 22bb0cdfd..c01769b5e 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h @@ -166,6 +166,7 @@ class KRRichTextView : public IKRRenderViewExport { private: std::shared_ptr paragraph_; std::shared_ptr shadow_; + bool use_styled_string_ = false; float last_draw_frame_width_ = -1.0; float line_break_margin_ = 0; KRParagraphSelectionInfo selection_rects_; From a50018cd507503e0386bf7f8e75866db63948f3e Mon Sep 17 00:00:00 2001 From: artin Date: Sun, 21 Jun 2026 16:10:26 +0800 Subject: [PATCH 002/187] fix(compose): avoid redundant inline text layout --- .../kuikly/compose/foundation/text/BasicText.kt | 10 ++++++---- .../kuikly/compose/ui/text/MultiParagraph.kt | 14 ++++++++++++++ core/build.2.0.ohos.gradle.kts | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicText.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicText.kt index 6bcf421ad..f870f50a3 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicText.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicText.kt @@ -194,7 +194,7 @@ private fun _BasicText( color: ColorProducer? = null ) { val inText = annoText ?: AnnotatedString(text ?: "") - val hasInlineContent = inlineContent.isNotEmpty() + val hasInlineContent = inlineContent.isNotEmpty() && inText.hasInlineContent() if (hasInlineContent) { LayoutWithLinksAndInlineContent( @@ -257,8 +257,10 @@ private fun LayoutWithLinksAndInlineContent( softWrap = softWrap, maxLines = maxLines, onTextLayout = { result -> - // 获取 placeholder 的位置信息 - measuredPlaceholderPositions.value = result.placeholderRects + val placeholderRects = result.placeholderRects + if (measuredPlaceholderPositions.value != placeholderRects) { + measuredPlaceholderPositions.value = placeholderRects + } onTextLayout?.invoke(result) }, inlineContent = inlineContent, @@ -406,4 +408,4 @@ val LocalTextStyle get() = com.tencent.kuikly.compose.material3.LocalTextStyle ) @Composable fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) = - com.tencent.kuikly.compose.material3.ProvideTextStyle(value, content) \ No newline at end of file + com.tencent.kuikly.compose.material3.ProvideTextStyle(value, content) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/MultiParagraph.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/MultiParagraph.kt index ae7e53d81..cf4d3110c 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/MultiParagraph.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/MultiParagraph.kt @@ -35,5 +35,19 @@ class MultiParagraph( val lineCount: Int = 0, val placeholderRects: List ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MultiParagraph) return false + if (lineCount != other.lineCount) return false + if (placeholderRects != other.placeholderRects) return false + + return true + } + + override fun hashCode(): Int { + var result = lineCount + result = 31 * result + placeholderRects.hashCode() + return result + } } diff --git a/core/build.2.0.ohos.gradle.kts b/core/build.2.0.ohos.gradle.kts index 51e4b2603..c2a243d10 100644 --- a/core/build.2.0.ohos.gradle.kts +++ b/core/build.2.0.ohos.gradle.kts @@ -55,7 +55,7 @@ kotlin { includeDirs(file("src/ohosArm64Main/ohosInterop/include")) // Add HarmonyOS SDK include paths (Windows only) - if (System.getProperty("os.name").lowercase().contains("windows")) { + if (System.getProperty("os.name").toLowerCase().contains("windows")) { val ohosSdkHome = System.getenv("OHOS_SDK_HOME") if (!ohosSdkHome.isNullOrEmpty()) { includeDirs( From b61231014b7e350371297c928cb386870eeb5b26 Mon Sep 17 00:00:00 2001 From: artin Date: Sun, 21 Jun 2026 20:17:48 +0800 Subject: [PATCH 003/187] fix(compose): refresh native frame on placement offset changes --- .../com/tencent/kuikly/compose/ui/node/NodeCoordinator.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeCoordinator.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeCoordinator.kt index 5b8c7afcd..28784f3e1 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeCoordinator.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeCoordinator.kt @@ -365,7 +365,8 @@ internal abstract class NodeCoordinator( // explicitLayer: GraphicsLayer? ) { updateLayerBlock(layerBlock) - if (this.position != position) { + val positionChanged = this.position != position + if (positionChanged) { this.position = position layoutNode.layoutDelegate.measurePassDelegate .notifyChildrenUsingCoordinatesWhilePlacing() @@ -378,7 +379,7 @@ internal abstract class NodeCoordinator( invalidateAlignmentLinesFromPositionChange() layoutNode.owner?.onLayoutChange(layoutNode) } - if (this == layoutNode.innerCoordinator) { + if (this == layoutNode.innerCoordinator || positionChanged) { layoutNode.updateKuiklyViewFrame(this) } From 2ee7c8252dc6a760134b9e62304043b1980d4d6d Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 22 Jun 2026 01:32:54 +0800 Subject: [PATCH 004/187] fix(compose): guard profiler trace stack updates --- .../compose/profiler/RecompositionTracker.kt | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTracker.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTracker.kt index 52bea6c05..e8af385ac 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTracker.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTracker.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.InternalComposeTracingApi import androidx.compose.runtime.CompositionTracer import androidx.compose.runtime.snapshots.Snapshot import com.tencent.kuikly.compose.profiler.filter.FilterChain +import com.tencent.kuikly.compose.ui.createSynchronizedObject +import com.tencent.kuikly.compose.ui.synchronized import com.tencent.kuikly.core.datetime.DateTime import kotlin.concurrent.Volatile import kotlin.random.Random @@ -118,6 +120,7 @@ internal class RecompositionTracker { /** CompositionTracer 追踪栈,记录嵌套的 Composable 调用 */ private val traceStack = mutableListOf() + private val traceStackLock = createSynchronizedObject() /** * Overlay 子树过滤深度计数器。 @@ -287,8 +290,10 @@ internal class RecompositionTracker { */ fun stop() { unregisterSnapshotObserver() - traceStack.clear() - overlayFilterDepth = 0 + synchronized(traceStackLock) { + traceStack.clear() + overlayFilterDepth = 0 + } hasPreciseScopeMapping = false filterChain = null // 清理过滤链资源 } @@ -437,18 +442,20 @@ internal class RecompositionTracker { if (!currentFrameSampled) { return } - // If already inside an Overlay subtree, just increment depth and skip - if (overlayFilterDepth > 0) { - overlayFilterDepth++ - return - } - // Check if this composable is an Overlay internal (e.g. ProfilerOverlaySlot) - if (isOverlayComposable(info)) { - overlayFilterDepth = 1 - return + synchronized(traceStackLock) { + // If already inside an Overlay subtree, just increment depth and skip + if (overlayFilterDepth > 0) { + overlayFilterDepth++ + return + } + // Check if this composable is an Overlay internal (e.g. ProfilerOverlaySlot) + if (isOverlayComposable(info)) { + overlayFilterDepth = 1 + return + } + traceStack.add(TraceEntry(key, info, DateTime.currentTimestamp(), dirty1, dirty2, + scopeKeySnapshot = compositionObserver.getCurrentScopeKey())) } - traceStack.add(TraceEntry(key, info, DateTime.currentTimestamp(), dirty1, dirty2, - scopeKeySnapshot = compositionObserver.getCurrentScopeKey())) } /** @@ -457,14 +464,20 @@ internal class RecompositionTracker { */ private fun onComposableTraceEnd() { if (!currentFrameSampled) return - // If inside an Overlay subtree, just decrement depth and skip - if (overlayFilterDepth > 0) { - overlayFilterDepth-- - return - } - if (traceStack.isEmpty()) return + val (entry, parentInfo) = synchronized(traceStackLock) { + // If inside an Overlay subtree, just decrement depth and skip + if (overlayFilterDepth > 0) { + overlayFilterDepth-- + return + } + if (traceStack.isEmpty()) return - val entry = traceStack.removeAt(traceStack.lastIndex) + val poppedEntry = traceStack.removeAt(traceStack.lastIndex) + val poppedParentInfo = traceStack.lastOrNull { entry -> + extractComposableName(entry.info) != "" + }?.info + poppedEntry to poppedParentInfo + } // 根据过滤链判断是否过滤此 Composable if (shouldFilterComposable(entry.info)) { @@ -474,10 +487,6 @@ internal class RecompositionTracker { val now = DateTime.currentTimestamp() val durationMs = now - entry.startTimeMs // 跳过 层级,找到最近的有名父 Composable - val parentInfo = traceStack.lastOrNull { entry -> - extractComposableName(entry.info) != "" - }?.info - val composableName = extractComposableName(entry.info) // 是 lambda content slot,无具体名称,不记录也不计数 From b79a43f266455111126d6b4f6910b685098f6667 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 22 Jun 2026 10:06:40 +0800 Subject: [PATCH 005/187] fix(compose): keep profiler log output crash-safe --- .../profiler/output/LogOutputStrategy.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt index 356e0090c..67ce50a6f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt @@ -55,12 +55,12 @@ class LogOutputStrategy( for (event in events) { when (event) { is RecompositionFrameStartEvent -> { - KLog.d(TAG, "Frame #${event.frameId} START (ts=${event.timestampMs}ms)") + logDebug("Frame #${event.frameId} START (ts=${event.timestampMs}ms)") indent++ } is RecompositionFrameEndEvent -> { indent = (indent - 1).coerceAtLeast(0) - KLog.d(TAG, "Frame #${event.frameId} END (duration=${event.durationMs}ms, recomposed=${event.recomposedCount})") + logDebug("Frame #${event.frameId} END (duration=${event.durationMs}ms, recomposed=${event.recomposedCount})") } is ComposableRecomposedEvent -> { if (event.composableName == "") continue @@ -72,7 +72,7 @@ class LogOutputStrategy( " triggers=[${event.triggerStates.joinToString(", ")}]" } else "" val indent2 = indentStr(indent) - KLog.d(TAG, "${indent2}RECOMPOSED: ${event.composableName}$locationInfo (${event.durationMs}ms)$scopeInfo$parentInfo$paramInfo$statesInfo") + logDebug("${indent2}RECOMPOSED: ${event.composableName}$locationInfo (${event.durationMs}ms)$scopeInfo$parentInfo$paramInfo$statesInfo") } else -> { /* TouchContextEvent / ScrollContextEvent — not logged per-frame */ } } @@ -80,20 +80,20 @@ class LogOutputStrategy( } override fun onReportReady(report: RecompositionReport) { - KLog.i(TAG, "=== Recomposition Report ===") - KLog.i(TAG, "Session: ${report.sessionId}") - KLog.i(TAG, "Duration: ${report.durationMs}ms | Frames: ${report.totalFrames} | Recompositions: ${report.totalRecompositions}") + logInfo("=== Recomposition Report ===") + logInfo("Session: ${report.sessionId}") + logInfo("Duration: ${report.durationMs}ms | Frames: ${report.totalFrames} | Recompositions: ${report.totalRecompositions}") if (report.hotspots.isNotEmpty()) { - KLog.i(TAG, "--- HOTSPOTS ---") + logInfo("--- HOTSPOTS ---") for (hotspot in report.hotspots) { val loc = if (hotspot.sourceLocation != null) " @${hotspot.sourceLocation}" else "" - KLog.i(TAG, " ${hotspot.name}$loc: ${hotspot.recompositionCount}x (avg=${formatFloat(hotspot.avgDurationMs)}ms, max=${hotspot.maxDurationMs}ms)") + logInfo(" ${hotspot.name}$loc: ${hotspot.recompositionCount}x (avg=${formatFloat(hotspot.avgDurationMs)}ms, max=${hotspot.maxDurationMs}ms)") } } if (report.composables.isNotEmpty()) { - KLog.i(TAG, "--- Composables ---") + logInfo("--- Composables ---") for (stats in report.composables) { val marker = if (stats.isHotspot) " [HOTSPOT]" else "" val paramInfo = if (stats.paramChangeFrequency.isNotEmpty()) { @@ -110,7 +110,7 @@ class LogOutputStrategy( " no state change" } val loc = if (stats.sourceLocation != null) " @${stats.sourceLocation}" else "" - KLog.i(TAG, " ${stats.name}$loc: ${stats.recompositionCount}x (avg=${formatFloat(stats.avgDurationMs)}ms)$marker$paramInfo$stateInfo") + logInfo(" ${stats.name}$loc: ${stats.recompositionCount}x (avg=${formatFloat(stats.avgDurationMs)}ms)$marker$paramInfo$stateInfo") // Scope 分布行 if (stats.scopeDistribution.isNotEmpty() || stats.noScopeRecompositions > 0) { val scopeInfo = if (stats.scopeDistribution.isNotEmpty()) { @@ -121,12 +121,28 @@ class LogOutputStrategy( } else { "{}" } - KLog.i(TAG, " → scopes: $scopeInfo, no-scope: ${stats.noScopeRecompositions}") + logInfo(" → scopes: $scopeInfo, no-scope: ${stats.noScopeRecompositions}") } } } } + private fun logDebug(message: String) { + safeKLog(message) { KLog.d(TAG, message) } + } + + private fun logInfo(message: String) { + safeKLog(message) { KLog.i(TAG, message) } + } + + private inline fun safeKLog(message: String, block: () -> Unit) { + try { + block() + } catch (_: Throwable) { + println("[KLog][$TAG]:$message") + } + } + private fun indentStr(level: Int): String = " ".repeat(level) private fun buildParamChangeString(event: ComposableRecomposedEvent): String { From 34efdcbfd4405cf2ae4a7917c2cfbdb72c9407c0 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 22 Jun 2026 10:22:04 +0800 Subject: [PATCH 006/187] fix(compose): route profiler logs to platform logger --- .../output/ProfilerPlatformLog.android.kt | 26 +++++++++++++++++++ .../profiler/output/LogOutputStrategy.kt | 17 +++++------- .../profiler/output/ProfilerPlatformLog.js.kt | 24 +++++++++++++++++ .../output/ProfilerPlatformLog.native.kt | 24 +++++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 compose/src/androidMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.android.kt create mode 100644 compose/src/jsMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.js.kt create mode 100644 compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.native.kt diff --git a/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.android.kt b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.android.kt new file mode 100644 index 000000000..ecd1c10de --- /dev/null +++ b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.android.kt @@ -0,0 +1,26 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.profiler.output + +import android.util.Log + +internal actual fun profilerLogDebug(tag: String, message: String) { + Log.d(tag, message) +} + +internal actual fun profilerLogInfo(tag: String, message: String) { + Log.i(tag, message) +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt index 67ce50a6f..882c2d334 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/LogOutputStrategy.kt @@ -21,7 +21,6 @@ import com.tencent.kuikly.compose.profiler.RecompositionFrameEndEvent import com.tencent.kuikly.compose.profiler.RecompositionFrameStartEvent import com.tencent.kuikly.compose.profiler.RecompositionOutputStrategy import com.tencent.kuikly.compose.profiler.RecompositionReport -import com.tencent.kuikly.core.log.KLog /** * 日志输出策略。 @@ -128,19 +127,11 @@ class LogOutputStrategy( } private fun logDebug(message: String) { - safeKLog(message) { KLog.d(TAG, message) } + profilerLogDebug(TAG, message) } private fun logInfo(message: String) { - safeKLog(message) { KLog.i(TAG, message) } - } - - private inline fun safeKLog(message: String, block: () -> Unit) { - try { - block() - } catch (_: Throwable) { - println("[KLog][$TAG]:$message") - } + profilerLogInfo(TAG, message) } private fun indentStr(level: Int): String = " ".repeat(level) @@ -160,3 +151,7 @@ class LogOutputStrategy( return "$intPart.$fracPart" } } + +internal expect fun profilerLogDebug(tag: String, message: String) + +internal expect fun profilerLogInfo(tag: String, message: String) diff --git a/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.js.kt b/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.js.kt new file mode 100644 index 000000000..b296889d6 --- /dev/null +++ b/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.js.kt @@ -0,0 +1,24 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.profiler.output + +internal actual fun profilerLogDebug(tag: String, message: String) { + println("[$tag] $message") +} + +internal actual fun profilerLogInfo(tag: String, message: String) { + println("[$tag] $message") +} diff --git a/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.native.kt b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.native.kt new file mode 100644 index 000000000..b296889d6 --- /dev/null +++ b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/profiler/output/ProfilerPlatformLog.native.kt @@ -0,0 +1,24 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.profiler.output + +internal actual fun profilerLogDebug(tag: String, message: String) { + println("[$tag] $message") +} + +internal actual fun profilerLogInfo(tag: String, message: String) { + println("[$tag] $message") +} From 0989f41e968c4fa8c2df9e3fac65f5b6ca574db1 Mon Sep 17 00:00:00 2001 From: artin Date: Tue, 23 Jun 2026 06:11:05 +0800 Subject: [PATCH 007/187] fix(android): center odd line height leading --- core-render-android/build.2.1.21.gradle.kts | 3 +- .../component/text/KRRichTextBuilder.kt | 9 ++-- .../component/text/HRLineHeightSpanTest.java | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java diff --git a/core-render-android/build.2.1.21.gradle.kts b/core-render-android/build.2.1.21.gradle.kts index c46f14c91..ca12227a8 100644 --- a/core-render-android/build.2.1.21.gradle.kts +++ b/core-render-android/build.2.1.21.gradle.kts @@ -79,4 +79,5 @@ dependencies { compileOnly(project(":core")) implementation("androidx.appcompat:appcompat:1.4.2") implementation("androidx.dynamicanimation:dynamicanimation:1.0.0") -} \ No newline at end of file + testImplementation("junit:junit:4.13.2") +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 9dc6da8ad..132fbec76 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -51,8 +51,6 @@ import com.tencent.kuikly.core.render.android.css.ktx.toPxF import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.expand.component.KRTextProps import org.json.JSONObject -import kotlin.math.ceil -import kotlin.math.floor import kotlin.math.max /** @@ -451,8 +449,9 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { fm: Paint.FontMetricsInt ) { val additional: Int = height - (-fm.top + fm.bottom) - fm.top -= ceil((additional / 2.0f).toDouble()).toInt() - fm.bottom += floor((additional / 2.0f).toDouble()).toInt() + val topExtra = additional / 2 + fm.top -= topExtra + fm.bottom += additional - topExtra fm.ascent = fm.top fm.descent = fm.bottom } @@ -599,4 +598,4 @@ class KRPlaceholderSpan(private val spanProps: PlaceholderSpanProps): Replacemen return spanProps.height } -} \ No newline at end of file +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java new file mode 100644 index 000000000..bb5d89dcb --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java @@ -0,0 +1,41 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.expand.component.text; + +import static org.junit.Assert.assertEquals; + +import android.graphics.Paint; +import org.junit.Test; + +public class HRLineHeightSpanTest { + + @Test + public void oddLineHeightLeadingDoesNotPushBaselineDown() { + Paint.FontMetricsInt metrics = new Paint.FontMetricsInt(); + metrics.top = -13; + metrics.ascent = -13; + metrics.descent = 4; + metrics.bottom = 4; + + new HRLineHeightSpan(22).chooseHeight("", 0, 0, 0, 0, metrics); + + assertEquals(-15, metrics.top); + assertEquals(-15, metrics.ascent); + assertEquals(7, metrics.bottom); + assertEquals(7, metrics.descent); + assertEquals(22, metrics.bottom - metrics.top); + } +} From 7b9503dd5baea6d752525c8020796f381d4dfaba Mon Sep 17 00:00:00 2001 From: artin Date: Tue, 23 Jun 2026 16:16:00 +0800 Subject: [PATCH 008/187] fix(android): center line height around glyph metrics --- .../expand/component/text/KRRichTextBuilder.kt | 12 ++++++------ .../component/text/HRLineHeightSpanTest.java | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 132fbec76..c5fd8b721 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -448,12 +448,12 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { lineHeight: Int, fm: Paint.FontMetricsInt ) { - val additional: Int = height - (-fm.top + fm.bottom) - val topExtra = additional / 2 - fm.top -= topExtra - fm.bottom += additional - topExtra - fm.ascent = fm.top - fm.descent = fm.bottom + val additional: Int = height - (fm.descent - fm.ascent) + val ascentExtra = additional / 2 + fm.ascent -= ascentExtra + fm.descent += additional - ascentExtra + fm.top = fm.ascent + fm.bottom = fm.descent } } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java index bb5d89dcb..cb69d6fb4 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java @@ -38,4 +38,21 @@ public void oddLineHeightLeadingDoesNotPushBaselineDown() { assertEquals(7, metrics.descent); assertEquals(22, metrics.bottom - metrics.top); } + + @Test + public void lineHeightCentersAroundGlyphMetricsWhenFontPaddingDiffers() { + Paint.FontMetricsInt metrics = new Paint.FontMetricsInt(); + metrics.top = -18; + metrics.ascent = -13; + metrics.descent = 4; + metrics.bottom = 6; + + new HRLineHeightSpan(22).chooseHeight("", 0, 0, 0, 0, metrics); + + assertEquals(-15, metrics.top); + assertEquals(-15, metrics.ascent); + assertEquals(7, metrics.bottom); + assertEquals(7, metrics.descent); + assertEquals(22, metrics.bottom - metrics.top); + } } From b9920143a2308172145c2b7848313039138ccf07 Mon Sep 17 00:00:00 2001 From: artin Date: Tue, 23 Jun 2026 21:35:34 +0800 Subject: [PATCH 009/187] fix(android): center line height on glyph bounds --- .../component/text/KRRichTextBuilder.kt | 55 +++++++++++++-- .../text/HRLineHeightSpanGlyphTest.kt | 67 +++++++++++++++++++ 2 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index c5fd8b721..0aa256512 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -19,6 +19,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Paint +import android.graphics.Rect import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface @@ -438,7 +439,16 @@ class FontFamilySpan(fontFamily: String, typeFaceLoader: TypeFaceLoader?) : Type } } -class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { +class HRLineHeightSpan(internal val height: Int) : LineHeightSpan, LineHeightSpan.WithDensity { + + internal companion object { + fun applyCenteredLineHeight(height: Int, fm: Paint.FontMetricsInt, center: Int) { + fm.ascent = center - height / 2 + fm.descent = fm.ascent + height + fm.top = fm.ascent + fm.bottom = fm.descent + } + } override fun chooseHeight( text: CharSequence?, @@ -448,12 +458,43 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { lineHeight: Int, fm: Paint.FontMetricsInt ) { - val additional: Int = height - (fm.descent - fm.ascent) - val ascentExtra = additional / 2 - fm.ascent -= ascentExtra - fm.descent += additional - ascentExtra - fm.top = fm.ascent - fm.bottom = fm.descent + applyCenteredLineHeight(fm, (fm.ascent + fm.descent) / 2) + } + + override fun chooseHeight( + text: CharSequence, + start: Int, + end: Int, + spanstartv: Int, + lineHeight: Int, + fm: Paint.FontMetricsInt, + paint: TextPaint + ) { + val visualCenter = glyphVisualCenter(text, start, end, paint) + ?: ((fm.ascent + fm.descent) / 2) + applyCenteredLineHeight(fm, visualCenter) + } + + private fun applyCenteredLineHeight(fm: Paint.FontMetricsInt, center: Int) { + applyCenteredLineHeight(height, fm, center) + } + + private fun glyphVisualCenter( + text: CharSequence?, + start: Int, + end: Int, + paint: TextPaint + ): Int? { + if (text == null || start >= end) { + return null + } + val bounds = Rect() + paint.getTextBounds(text, start, end, bounds) + return if (bounds.isEmpty) { + null + } else { + (bounds.top + bounds.bottom) / 2 + } } } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt new file mode 100644 index 000000000..c074328f0 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt @@ -0,0 +1,67 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.expand.component.text + +import android.graphics.Paint +import org.junit.Assert.assertEquals +import org.junit.Test + +class HRLineHeightSpanGlyphTest { + + @Test + fun lineHeightCanCenterAroundActualGlyphBounds() { + val metrics = Paint.FontMetricsInt().apply { + top = -12 + ascent = -12 + descent = 4 + bottom = 4 + } + + HRLineHeightSpan.applyCenteredLineHeight( + height = 20, + fm = metrics, + center = -5 + ) + + assertEquals(-15, metrics.top) + assertEquals(-15, metrics.ascent) + assertEquals(5, metrics.bottom) + assertEquals(5, metrics.descent) + assertEquals(20, metrics.bottom - metrics.top) + } + + @Test + fun centeredLineHeightKeepsExactOddHeight() { + val metrics = Paint.FontMetricsInt().apply { + top = -10 + ascent = -10 + descent = 3 + bottom = 3 + } + + HRLineHeightSpan.applyCenteredLineHeight( + height = 17, + fm = metrics, + center = -4 + ) + + assertEquals(-12, metrics.top) + assertEquals(-12, metrics.ascent) + assertEquals(5, metrics.bottom) + assertEquals(5, metrics.descent) + assertEquals(17, metrics.bottom - metrics.top) + } +} From 54120fae3e29de6b9160a231217bec5cbaa3515e Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 02:51:33 +0800 Subject: [PATCH 010/187] fix(android): render slock inline code markers --- .../foundation/text/KuiklyTextExtension.kt | 14 ++++ .../component/text/KRRichTextBuilder.kt | 9 +++ .../component/text/KRRichTextViewDrawer.kt | 81 ++++++++++++++++++- .../tencent/kuikly/core/views/RichTextView.kt | 5 ++ .../com/tencent/kuikly/core/views/TextView.kt | 3 +- 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index c2c6f5d86..bd1dc3c02 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -53,6 +53,7 @@ import com.tencent.kuikly.core.views.TextAttr import com.tencent.kuikly.core.views.TextConst import com.tencent.kuikly.core.views.TextSpan +private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "ai.slock.markdown.inlineCode" // Returns platform-specific default font size private fun TextAttr.defaultFontSize(): Float { @@ -335,6 +336,15 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.end) } + // Slock fork-only inline-code marker. The Android RichText renderer uses this + // metadata span to draw old inline-code chrome from the final text layout. + val slockInlineCodeAnnotations = + annoText.getStringAnnotations(SLOCK_INLINE_CODE_ANNOTATION_TAG, 0, annoText.length) + slockInlineCodeAnnotations.forEach { range -> + positions.add(range.start) + positions.add(range.end) + } + // Collect placeholder info and positions val (placeholders, _) = if (annoText.hasInlineContent()) { annoText.resolveInlineContent(inlineContent) @@ -381,6 +391,10 @@ internal fun RichTextAttr.applyAnnotatedString( .filter { range -> !(end <= range.start || start >= range.end) } .forEach { range -> applySpanStyle(range.item, density) } + if (slockInlineCodeAnnotations.any { range -> !(end <= range.start || start >= range.end) }) { + slockInlineCode() + } + // Apply ParagraphStyle annoText.paragraphStyles .filter { range -> !(end <= range.start || start >= range.end) } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 0aa256512..bd0305113 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -51,6 +51,7 @@ import com.tencent.kuikly.core.render.android.css.ktx.toColor import com.tencent.kuikly.core.render.android.css.ktx.toPxF import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.expand.component.KRTextProps +import com.tencent.kuikly.core.views.TextConst import org.json.JSONObject import kotlin.math.max @@ -203,6 +204,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.backgroundImage.isNotEmpty()) { textSpans.add(LinearGradientForegroundSpan(spanProps.backgroundImage, layoutSizeGetter)) } + if (spanProps.slockInlineCode) { + textSpans.add(KRSlockInlineCodeSpan()) + } spanProps.textShadow?.let { if (!it.isEmpty()) { @@ -251,6 +255,7 @@ class TextSpanProps( val textDecoration: String val lineHeight: Float val backgroundImage: String + val slockInlineCode: Boolean var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -297,6 +302,8 @@ class TextSpanProps( defaultProps.lineHeight } backgroundImage = spanValue.optString(KRTextProps.PROP_KEY_BACKGROUND_IMAGE, defaultProps.backgroundImage) + slockInlineCode = spanValue.optInt(TextConst.SLOCK_INLINE_CODE, 0) == 1 || + spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE, false) val textShadowStr = spanValue.optString(KRTextProps.PROP_KEY_TEXT_SHADOW, "") textShadow = BoxShadow(textShadowStr, kuiklyContext) useDpFontSizeDim = spanValue.optInt(KRTextProps.PROP_KEY_TEXT_USE_DP_FONT_SIZE_DIM) == 1 @@ -330,6 +337,8 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { } } +class KRSlockInlineCodeSpan + /** * 字重span * @param fontWeight 字重 diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 814bc50bd..c8f00a2c6 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -16,6 +16,7 @@ package com.tencent.kuikly.core.render.android.expand.component.text import android.graphics.Canvas +import android.graphics.Paint import android.graphics.Path import android.graphics.RectF import android.os.Build @@ -28,8 +29,15 @@ import com.tencent.kuikly.core.render.android.expand.component.SelectionType import java.lang.ref.WeakReference import java.text.BreakIterator import java.util.Locale +import kotlin.math.max +import kotlin.math.min private const val INVALID_OFFSET = -1 +private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D +private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f +private const val SLOCK_INLINE_CODE_CORNER_RADIUS_RATIO = 2f / 15f /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -44,6 +52,16 @@ class KRRichTextViewDrawer(val textLayout: Layout) { private var selectionStart = -1 private var selectionEnd = -1 internal val hasSelection: Boolean get() = 0 <= selectionStart && selectionStart < selectionEnd + private val slockInlineCodeFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = SLOCK_INLINE_CODE_FILL_COLOR + } + private val slockInlineCodeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1f + color = SLOCK_INLINE_CODE_BORDER_COLOR + } + private val slockInlineCodeRect = RectF() private val wordIterator by lazy(LazyThreadSafetyMode.NONE) { WordIterator(textLayout.text, 0, textLayout.text.length, Locale.getDefault()) @@ -67,9 +85,70 @@ class KRRichTextViewDrawer(val textLayout: Layout) { * 将文本内容绘制到 [canvas],对接到 [Layout.draw]。 */ fun draw(canvas: Canvas) { + drawSlockInlineCodeBackgrounds(canvas) textLayout.draw(canvas) } + private fun drawSlockInlineCodeBackgrounds(canvas: Canvas) { + val spanned = textLayout.text as? Spanned ?: return + val spans = spanned.getSpans(0, spanned.length, KRSlockInlineCodeSpan::class.java) + if (spans.isEmpty()) return + + val paint = textLayout.paint + val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO + val radius = paint.textSize * SLOCK_INLINE_CODE_CORNER_RADIUS_RATIO + val fontMetrics = paint.fontMetrics + + spans.forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + if (start < 0 || end <= start) return@forEach + + val startLine = textLayout.getLineForOffset(start) + val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) + for (line in startLine..endLine) { + val lineStart = textLayout.getLineStart(line) + val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) + val segmentStart = max(start, lineStart) + val segmentEnd = min(end, lineVisibleEnd) + if (segmentEnd <= segmentStart) continue + + val startX = textLayout.getPrimaryHorizontal(segmentStart) + val endX = textLayout.getPrimaryHorizontal(segmentEnd) + val lineLeft = min(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + val lineRight = max(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + val left = max(lineLeft, min(startX, endX) - horizontalPadding) + val right = min(lineRight, max(startX, endX) + horizontalPadding) + if (right <= left) continue + + val baseline = textLayout.getLineBaseline(line).toFloat() + val top = max( + textLayout.getLineTop(line).toFloat(), + baseline + fontMetrics.ascent - verticalPadding + ) + val bottom = min( + textLayout.getLineBottom(line).toFloat(), + baseline + fontMetrics.descent + verticalPadding + ) + if (bottom <= top) continue + + slockInlineCodeRect.set(left, top, right, bottom) + canvas.drawRoundRect(slockInlineCodeRect, radius, radius, slockInlineCodeFillPaint) + canvas.drawRoundRect(slockInlineCodeRect, radius, radius, slockInlineCodeBorderPaint) + } + } + } + + private fun Layout.slockInlineCodeVisibleEnd(line: Int): Int { + val lineStart = getLineStart(line) + val ellipsisCount = getEllipsisCount(line) + if (ellipsisCount > 0) { + return (lineStart + getEllipsisStart(line)).coerceAtLeast(lineStart) + } + return getLineVisibleEnd(line) + } + internal fun setSelectionByCoordinate( x: Float, y: Float, @@ -413,4 +492,4 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index a64aac266..8d14c9b33 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -412,6 +412,11 @@ open class TextSpan : TextAttr(), ISpan { internal var text: String = "" private var clickHandlerFn: ((ClickParams) -> Unit)? = null + fun slockInlineCode(enabled: Boolean = true): TextSpan { + setProp(TextConst.SLOCK_INLINE_CODE, if (enabled) 1 else 0) + return this + } + /** * 单击事件的定义 * @param handler 事件处理函数 diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index 7d71026d1..6b19df70b 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -553,6 +553,7 @@ object TextConst { const val STROKE_WIDTH = "strokeWidth" const val TEXT_POST_PROCESSOR = "textPostProcessor" const val TEXT_USE_DP_FONT_SIZE_DIM = "useDpFontSizeDim" + const val SLOCK_INLINE_CODE = "slockInlineCode" const val SHADOW_METHOD_IS_LINE_BREAK_MARGIN = "isLineBreakMargin" const val PLACEHOLDER = "placeholder" @@ -590,4 +591,4 @@ fun ViewContainer<*, *>.Text(init: TextView.() -> Unit) { } else { addChild(TextView(), init) } -} \ No newline at end of file +} From 1745a32a13e678ad8625cca55d0fd4979de403ae Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 12:01:10 +0800 Subject: [PATCH 011/187] fix(android): draw inline code as chip rects --- .../foundation/text/KuiklyTextExtension.kt | 2 +- .../component/text/KRRichTextViewDrawer.kt | 46 +++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index bd1dc3c02..4f9cf6146 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -391,7 +391,7 @@ internal fun RichTextAttr.applyAnnotatedString( .filter { range -> !(end <= range.start || start >= range.end) } .forEach { range -> applySpanStyle(range.item, density) } - if (slockInlineCodeAnnotations.any { range -> !(end <= range.start || start >= range.end) }) { + if (slockInlineCodeAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCode() } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index c8f00a2c6..1d5f9a4f9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -35,9 +35,9 @@ import kotlin.math.min private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() -private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f -private const val SLOCK_INLINE_CODE_CORNER_RADIUS_RATIO = 2f / 15f +private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -97,8 +97,10 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO - val radius = paint.textSize * SLOCK_INLINE_CODE_CORNER_RADIUS_RATIO + val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics + val layoutLeft = 0f + val layoutRight = textLayout.width.toFloat() spans.forEach { span -> val start = spanned.getSpanStart(span) @@ -114,28 +116,34 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val segmentEnd = min(end, lineVisibleEnd) if (segmentEnd <= segmentStart) continue - val startX = textLayout.getPrimaryHorizontal(segmentStart) - val endX = textLayout.getPrimaryHorizontal(segmentEnd) - val lineLeft = min(textLayout.getLineLeft(line), textLayout.getLineRight(line)) - val lineRight = max(textLayout.getLineLeft(line), textLayout.getLineRight(line)) - val left = max(lineLeft, min(startX, endX) - horizontalPadding) - val right = min(lineRight, max(startX, endX) + horizontalPadding) + val startX = + if (segmentStart <= lineStart) { + layoutLeft + } else { + textLayout.getPrimaryHorizontal(segmentStart) + } + val endX = + if (segmentEnd >= lineVisibleEnd) { + textLayout.getLineRight(line) + } else { + textLayout.getPrimaryHorizontal(segmentEnd) + } + val left = max(layoutLeft, min(startX, endX) - horizontalPadding) + val right = min(layoutRight, max(startX, endX) + horizontalPadding) if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() - val top = max( - textLayout.getLineTop(line).toFloat(), - baseline + fontMetrics.ascent - verticalPadding - ) - val bottom = min( - textLayout.getLineBottom(line).toFloat(), - baseline + fontMetrics.descent + verticalPadding - ) + val textTop = baseline + fontMetrics.ascent - verticalPadding + val textBottom = baseline + fontMetrics.descent + verticalPadding + val height = max(textBottom - textTop, minHeight) + val centerY = (textTop + textBottom) / 2f + val top = centerY - height / 2f + val bottom = centerY + height / 2f if (bottom <= top) continue slockInlineCodeRect.set(left, top, right, bottom) - canvas.drawRoundRect(slockInlineCodeRect, radius, radius, slockInlineCodeFillPaint) - canvas.drawRoundRect(slockInlineCodeRect, radius, radius, slockInlineCodeBorderPaint) + canvas.drawRect(slockInlineCodeRect, slockInlineCodeFillPaint) + canvas.drawRect(slockInlineCodeRect, slockInlineCodeBorderPaint) } } } From 0fb5d8be4ec614d7178416939680464ea2643b1e Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 14:52:02 +0800 Subject: [PATCH 012/187] fix(android): restore inline code edge padding --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 1d5f9a4f9..4117887cd 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -35,7 +35,7 @@ import kotlin.math.min private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() -private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f From 29927857bb58ec927f2349455975623452fd5ab4 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 15:20:37 +0800 Subject: [PATCH 013/187] fix(android): darken inline code border --- .../component/text/KRRichTextViewDrawer.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 4117887cd..0a487694b 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -38,6 +38,7 @@ private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f +private const val SLOCK_INLINE_CODE_BORDER_WIDTH = 1f /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -57,8 +58,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { color = SLOCK_INLINE_CODE_FILL_COLOR } private val slockInlineCodeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = 1f + style = Paint.Style.FILL color = SLOCK_INLINE_CODE_BORDER_COLOR } private val slockInlineCodeRect = RectF() @@ -100,7 +100,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics val layoutLeft = 0f - val layoutRight = textLayout.width.toFloat() spans.forEach { span -> val start = spanned.getSpanStart(span) @@ -128,8 +127,8 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { textLayout.getPrimaryHorizontal(segmentEnd) } - val left = max(layoutLeft, min(startX, endX) - horizontalPadding) - val right = min(layoutRight, max(startX, endX) + horizontalPadding) + val left = min(startX, endX) - horizontalPadding + val right = max(startX, endX) + horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() @@ -143,11 +142,19 @@ class KRRichTextViewDrawer(val textLayout: Layout) { slockInlineCodeRect.set(left, top, right, bottom) canvas.drawRect(slockInlineCodeRect, slockInlineCodeFillPaint) - canvas.drawRect(slockInlineCodeRect, slockInlineCodeBorderPaint) + canvas.drawSlockInlineCodeBorder(left, top, right, bottom) } } } + private fun Canvas.drawSlockInlineCodeBorder(left: Float, top: Float, right: Float, bottom: Float) { + val borderWidth = SLOCK_INLINE_CODE_BORDER_WIDTH + drawRect(left, top, right, top + borderWidth, slockInlineCodeBorderPaint) + drawRect(left, bottom - borderWidth, right, bottom, slockInlineCodeBorderPaint) + drawRect(left, top, left + borderWidth, bottom, slockInlineCodeBorderPaint) + drawRect(right - borderWidth, top, right, bottom, slockInlineCodeBorderPaint) + } + private fun Layout.slockInlineCodeVisibleEnd(line: Int): Int { val lineStart = getLineStart(line) val ellipsisCount = getEllipsisCount(line) From 25bf6eae2a6ccba2c5b9580620e660231224e426 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 15:47:18 +0800 Subject: [PATCH 014/187] fix(android): draw crisp inline code borders --- .../component/text/KRRichTextViewDrawer.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 0a487694b..3f50bc8dc 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -29,6 +29,8 @@ import com.tencent.kuikly.core.render.android.expand.component.SelectionType import java.lang.ref.WeakReference import java.text.BreakIterator import java.util.Locale +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.max import kotlin.math.min @@ -57,9 +59,10 @@ class KRRichTextViewDrawer(val textLayout: Layout) { style = Paint.Style.FILL color = SLOCK_INLINE_CODE_FILL_COLOR } - private val slockInlineCodeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val slockInlineCodeBorderPaint = Paint().apply { style = Paint.Style.FILL color = SLOCK_INLINE_CODE_BORDER_COLOR + isAntiAlias = false } private val slockInlineCodeRect = RectF() @@ -149,10 +152,14 @@ class KRRichTextViewDrawer(val textLayout: Layout) { private fun Canvas.drawSlockInlineCodeBorder(left: Float, top: Float, right: Float, bottom: Float) { val borderWidth = SLOCK_INLINE_CODE_BORDER_WIDTH - drawRect(left, top, right, top + borderWidth, slockInlineCodeBorderPaint) - drawRect(left, bottom - borderWidth, right, bottom, slockInlineCodeBorderPaint) - drawRect(left, top, left + borderWidth, bottom, slockInlineCodeBorderPaint) - drawRect(right - borderWidth, top, right, bottom, slockInlineCodeBorderPaint) + val borderLeft = floor(left) + val borderTop = floor(top) + val borderRight = ceil(right) + val borderBottom = ceil(bottom) + drawRect(borderLeft, borderTop, borderRight, borderTop + borderWidth, slockInlineCodeBorderPaint) + drawRect(borderLeft, borderBottom - borderWidth, borderRight, borderBottom, slockInlineCodeBorderPaint) + drawRect(borderLeft, borderTop, borderLeft + borderWidth, borderBottom, slockInlineCodeBorderPaint) + drawRect(borderRight - borderWidth, borderTop, borderRight, borderBottom, slockInlineCodeBorderPaint) } private fun Layout.slockInlineCodeVisibleEnd(line: Int): Int { From 22eebd6f18c4ebf87b9c65c2e9c5f1a1f7a777f1 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 16:17:35 +0800 Subject: [PATCH 015/187] fix(android): match inline code tag border width --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 3f50bc8dc..7c2fe800c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -40,7 +40,8 @@ private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f -private const val SLOCK_INLINE_CODE_BORDER_WIDTH = 1f +private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f +private const val SLOCK_INLINE_CODE_BORDER_MIN_WIDTH = 2f /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -151,7 +152,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } private fun Canvas.drawSlockInlineCodeBorder(left: Float, top: Float, right: Float, bottom: Float) { - val borderWidth = SLOCK_INLINE_CODE_BORDER_WIDTH + val borderWidth = max(SLOCK_INLINE_CODE_BORDER_MIN_WIDTH, textLayout.paint.density * SLOCK_INLINE_CODE_BORDER_WIDTH_DP) val borderLeft = floor(left) val borderTop = floor(top) val borderRight = ceil(right) From f0975f72e066dbc3ad8c75d04bab4e3c56ef5cae Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 16:42:21 +0800 Subject: [PATCH 016/187] fix(android): reserve inline code side margin --- .../component/text/KRRichTextBuilder.kt | 75 ++++++++++++++++++- .../component/text/KRRichTextViewDrawer.kt | 6 +- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index bd0305113..1162ccc4c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -53,8 +53,12 @@ import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.expand.component.KRTextProps import com.tencent.kuikly.core.views.TextConst import org.json.JSONObject +import kotlin.math.ceil import kotlin.math.max +private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 4f / 15f + /** * 富文本构造器 */ @@ -94,17 +98,40 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { } spannedBuilder.append(buildSpannedString { // 记录 Span 对应的文字范围 + val spanStart = spannedBuilder.length + val spanEnd = spannedBuilder.length + spanProps.text.length spanTextRanges.add( SpanTextRange( index, - spannedBuilder.length, - spannedBuilder.length + spanProps.text.length + spanStart, + spanEnd ) ) inSpans(spans) { append(spanProps.text) } }) + if (spanProps is TextSpanProps && spanProps.slockInlineCode && spanProps.text.isNotEmpty()) { + val spanEnd = spannedBuilder.length + val spanStart = spanEnd - spanProps.text.length + spannedBuilder.setSpan( + KRSlockInlineCodeEdgePaddingSpan( + padStart = true, + padEnd = spanProps.text.length == 1 + ), + spanStart, + spanStart + 1, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + if (spanProps.text.length > 1) { + spannedBuilder.setSpan( + KRSlockInlineCodeEdgePaddingSpan(padStart = false, padEnd = true), + spanEnd - 1, + spanEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } } } @@ -339,6 +366,50 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan +private class KRSlockInlineCodeEdgePaddingSpan( + private val padStart: Boolean, + private val padEnd: Boolean +) : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (text == null) return 0 + return ceil(paint.measureText(text, start, end) + startPadding(paint) + endPadding(paint)).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text == null) return + canvas.drawText(text, start, end, x + startPadding(paint), y.toFloat(), paint) + } + + private fun startPadding(paint: Paint): Float { + return if (padStart) edgePadding(paint) else 0f + } + + private fun endPadding(paint: Paint): Float { + return if (padEnd) edgePadding(paint) else 0f + } + + private fun edgePadding(paint: Paint): Float { + return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) + } +} + /** * 字重span * @param fontWeight 字重 diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 7c2fe800c..e727456d9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -38,6 +38,7 @@ private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f @@ -100,6 +101,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics @@ -131,8 +133,8 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { textLayout.getPrimaryHorizontal(segmentEnd) } - val left = min(startX, endX) - horizontalPadding - val right = max(startX, endX) + horizontalPadding + val left = min(startX, endX) + if (segmentStart == start) horizontalMargin else -horizontalPadding + val right = max(startX, endX) + if (segmentEnd == end) -horizontalMargin else horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From 60e8abe297025d8b616978ccc6dfeb42e9bff2a1 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 20:16:18 +0800 Subject: [PATCH 017/187] fix(android): accept pull refresh list method --- .../render/android/expand/component/list/KRRecyclerView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt index 5bb9472d9..a2d27ba80 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt @@ -507,6 +507,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? { return when (method) { + METHOD_SET_HAS_PULL_TO_REFRESH -> null METHOD_CONTENT_OFFSET -> setContentOffset(params) METHOD_CONTENT_INSET_WHEN_END_DRAG -> contentInsetWhenEndDrag(params) METHOD_CONTENT_INSET -> contentInset(params) @@ -1505,6 +1506,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private const val METHOD_CONTENT_INSET = "contentInset" // 设置内容边距 private const val METHOD_ABORT_CONTENT_OFFSET_ANIMATE = "abortContentOffsetAnimate" // 停止滚动动画 private const val METHOD_PREPARE_FOR_COMPOSE_REUSE = "prepareForComposeReuse" // Compose DSL 复用前重置瞬态 + private const val METHOD_SET_HAS_PULL_TO_REFRESH = "setHasPullToRefresh" private const val NESTED_SCROLL = "nestedScroll" @@ -2030,4 +2032,4 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi } return super.canScrollVertically(direction) } -} \ No newline at end of file +} From 9b4d75999dcc6ae7e5f28e272b37c86d822e53d2 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 23:01:12 +0800 Subject: [PATCH 018/187] fix(android): let inline code wrap naturally --- .../component/text/KRRichTextBuilder.kt | 70 ------------------- .../component/text/KRRichTextViewDrawer.kt | 17 +++-- 2 files changed, 13 insertions(+), 74 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 1162ccc4c..b336d71e9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -53,12 +53,8 @@ import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.expand.component.KRTextProps import com.tencent.kuikly.core.views.TextConst import org.json.JSONObject -import kotlin.math.ceil import kotlin.math.max -private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 7f / 15f -private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 4f / 15f - /** * 富文本构造器 */ @@ -111,28 +107,6 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { append(spanProps.text) } }) - if (spanProps is TextSpanProps && spanProps.slockInlineCode && spanProps.text.isNotEmpty()) { - val spanEnd = spannedBuilder.length - val spanStart = spanEnd - spanProps.text.length - spannedBuilder.setSpan( - KRSlockInlineCodeEdgePaddingSpan( - padStart = true, - padEnd = spanProps.text.length == 1 - ), - spanStart, - spanStart + 1, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - if (spanProps.text.length > 1) { - spannedBuilder.setSpan( - KRSlockInlineCodeEdgePaddingSpan(padStart = false, padEnd = true), - spanEnd - 1, - spanEnd, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } - } } if (textProps.richTextHeadIndent != 0) { @@ -366,50 +340,6 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan -private class KRSlockInlineCodeEdgePaddingSpan( - private val padStart: Boolean, - private val padEnd: Boolean -) : ReplacementSpan() { - - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - if (text == null) return 0 - return ceil(paint.measureText(text, start, end) + startPadding(paint) + endPadding(paint)).toInt() - } - - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - canvas.drawText(text, start, end, x + startPadding(paint), y.toFloat(), paint) - } - - private fun startPadding(paint: Paint): Float { - return if (padStart) edgePadding(paint) else 0f - } - - private fun endPadding(paint: Paint): Float { - return if (padEnd) edgePadding(paint) else 0f - } - - private fun edgePadding(paint: Paint): Float { - return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) - } -} - /** * 字重span * @param fontWeight 字重 diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index e727456d9..710de562d 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -38,7 +38,6 @@ private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f -private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f @@ -101,11 +100,11 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO - val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics val layoutLeft = 0f + val layoutRight = textLayout.width.toFloat() spans.forEach { span -> val start = spanned.getSpanStart(span) @@ -133,8 +132,18 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { textLayout.getPrimaryHorizontal(segmentEnd) } - val left = min(startX, endX) + if (segmentStart == start) horizontalMargin else -horizontalPadding - val right = max(startX, endX) + if (segmentEnd == end) -horizontalMargin else horizontalPadding + val segmentLeft = min(startX, endX) + val segmentRight = max(startX, endX) + val left = if (segmentStart == start) { + max(layoutLeft, segmentLeft - horizontalPadding) + } else { + segmentLeft + } + val right = if (segmentEnd == end) { + min(layoutRight, segmentRight + horizontalPadding) + } else { + segmentRight + } if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From 55b509e474f1ca2363b73b2c74b666310bb020f5 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 23:27:19 +0800 Subject: [PATCH 019/187] fix(android): keep inline code path atoms together --- .../component/text/KRRichTextBuilder.kt | 111 ++++++++++++++++-- 1 file changed, 100 insertions(+), 11 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index b336d71e9..ee35fc34d 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -53,6 +53,7 @@ import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.expand.component.KRTextProps import com.tencent.kuikly.core.views.TextConst import org.json.JSONObject +import kotlin.math.ceil import kotlin.math.max /** @@ -92,21 +93,24 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - spannedBuilder.append(buildSpannedString { - // 记录 Span 对应的文字范围 - val spanStart = spannedBuilder.length - val spanEnd = spannedBuilder.length + spanProps.text.length - spanTextRanges.add( - SpanTextRange( - index, - spanStart, - spanEnd - ) + val spanStart = spannedBuilder.length + val spanText = spanProps.text + val spanEnd = spanStart + spanText.length + spanTextRanges.add( + SpanTextRange( + index, + spanStart, + spanEnd ) + ) + spannedBuilder.append(buildSpannedString { inSpans(spans) { - append(spanProps.text) + append(spanText) } }) + if (spanProps is TextSpanProps && spanProps.slockInlineCode) { + spannedBuilder.applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) + } } } if (textProps.richTextHeadIndent != 0) { @@ -340,6 +344,91 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan +private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { + var index = start + while (index < end) { + if (this[index].isWhitespace()) { + index++ + continue + } + val rangeStart = index + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val textStart = index + while (index < end && + !this[index].isWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + index++ + } + var textLength = index - textStart + while (textLength in 1..2 && + index < end && + this[index].isSlockInlineCodeBreakSeparator() + ) { + val separatorStart = index + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val nextTextStart = index + while (index < end && + !this[index].isWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + index++ + } + if (index <= nextTextStart) { + index = separatorStart + break + } + textLength = index - textStart + } + if (index > textStart) { + setSpan( + KRSlockInlineCodeAtomicTextSpan(), + rangeStart, + index, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } +} + +private fun Char.isSlockInlineCodeBreakSeparator(): Boolean = + this == '/' || this == '\\' || this == '.' || this == '-' || this == ':' + +private class KRSlockInlineCodeAtomicTextSpan : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int = if (text == null || start >= end) { + 0 + } else { + ceil((paint.measureText(text, start, end) + max(1f, paint.strokeWidth * 2f)).toDouble()).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text != null && start < end) { + canvas.drawText(text, start, end, x, y.toFloat(), paint) + } + } +} + /** * 字重span * @param fontWeight 字重 From dd491e132e4ed7c4be2541010f4f6579746c6135 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 25 Jun 2026 23:37:19 +0800 Subject: [PATCH 020/187] fix(android): tighten inline code side padding --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 710de562d..b766702a6 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -37,7 +37,7 @@ import kotlin.math.min private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() -private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 6f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f From 04aae11c2ea6ea5e3f241d383c79a1452dea5122 Mon Sep 17 00:00:00 2001 From: artin Date: Fri, 26 Jun 2026 00:22:21 +0800 Subject: [PATCH 021/187] fix(android): correct inline code edge margin --- .../component/text/KRRichTextBuilder.kt | 54 +++++++++++++++++-- .../component/text/KRRichTextViewDrawer.kt | 13 ++--- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index ee35fc34d..f6f94ef93 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -56,6 +56,9 @@ import org.json.JSONObject import kotlin.math.ceil import kotlin.math.max +private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 3f / 15f + /** * 富文本构造器 */ @@ -346,6 +349,7 @@ class KRSlockInlineCodeSpan private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { var index = start + var firstAtom = true while (index < end) { if (this[index].isWhitespace()) { index++ @@ -385,12 +389,15 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In textLength = index - textStart } if (index > textStart) { + val padStart = firstAtom + val padEnd = !hasSlockInlineCodeAtomAfter(index, end) setSpan( - KRSlockInlineCodeAtomicTextSpan(), + KRSlockInlineCodeAtomicTextSpan(padStart, padEnd), rangeStart, index, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) + firstAtom = false } } } @@ -398,7 +405,32 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In private fun Char.isSlockInlineCodeBreakSeparator(): Boolean = this == '/' || this == '\\' || this == '.' || this == '-' || this == ':' -private class KRSlockInlineCodeAtomicTextSpan : ReplacementSpan() { +private fun CharSequence.hasSlockInlineCodeAtomAfter(start: Int, end: Int): Boolean { + var index = start + while (index < end) { + if (this[index].isWhitespace()) { + index++ + continue + } + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val textStart = index + while (index < end && + !this[index].isWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + index++ + } + if (index > textStart) return true + } + return false +} + +private class KRSlockInlineCodeAtomicTextSpan( + private val padStart: Boolean, + private val padEnd: Boolean +) : ReplacementSpan() { override fun getSize( paint: Paint, @@ -409,7 +441,9 @@ private class KRSlockInlineCodeAtomicTextSpan : ReplacementSpan() { ): Int = if (text == null || start >= end) { 0 } else { - ceil((paint.measureText(text, start, end) + max(1f, paint.strokeWidth * 2f)).toDouble()).toInt() + val textWidth = paint.measureText(text, start, end) + val strokePadding = max(1f, paint.strokeWidth * 2f) + ceil((textWidth + strokePadding + startPadding(paint) + endPadding(paint)).toDouble()).toInt() } override fun draw( @@ -424,9 +458,21 @@ private class KRSlockInlineCodeAtomicTextSpan : ReplacementSpan() { paint: Paint ) { if (text != null && start < end) { - canvas.drawText(text, start, end, x, y.toFloat(), paint) + canvas.drawText(text, start, end, x + startPadding(paint), y.toFloat(), paint) } } + + private fun startPadding(paint: Paint): Float { + return if (padStart) edgePadding(paint) else 0f + } + + private fun endPadding(paint: Paint): Float { + return if (padEnd) edgePadding(paint) else 0f + } + + private fun edgePadding(paint: Paint): Float { + return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) + } } /** diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index b766702a6..1a5bff618 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -37,7 +37,8 @@ import kotlin.math.min private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() -private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 6f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 3f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f @@ -100,11 +101,11 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics val layoutLeft = 0f - val layoutRight = textLayout.width.toFloat() spans.forEach { span -> val start = spanned.getSpanStart(span) @@ -135,14 +136,14 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val segmentLeft = min(startX, endX) val segmentRight = max(startX, endX) val left = if (segmentStart == start) { - max(layoutLeft, segmentLeft - horizontalPadding) + segmentLeft + horizontalMargin } else { - segmentLeft + segmentLeft - horizontalPadding } val right = if (segmentEnd == end) { - min(layoutRight, segmentRight + horizontalPadding) + segmentRight - horizontalMargin } else { - segmentRight + segmentRight + horizontalPadding } if (right <= left) continue From 0f22838decadf3389596db203781bb5d1f6525fa Mon Sep 17 00:00:00 2001 From: zenipchen Date: Fri, 26 Jun 2026 14:44:22 +0800 Subject: [PATCH 022/187] fix(ohos): reduce LazyColumn scroll white-screen on HarmonyOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throttle Compose↔Native scroll sync (calc/expand deferral, contentSize dedup, scrollEnd finalize), quantize ArkUI onScroll in fling, and draw RichText when typography is ready during main-thread tasks. Co-authored-by: Cursor --- ...ose-all-sample-ohos-scroll-white-screen.md | 266 ++++++++++++++++++ .../compose/gestures/KuiklyScrollInfo.kt | 15 + .../compose/gestures/KuiklyScrollTrace.kt | 68 +++++ .../compose/scroller/ContentSizeExtensions.kt | 41 ++- .../compose/ui/layout/SubcomposeLayout.kt | 46 +-- .../kuikly/compose/ui/node/RootNodeOwner.kt | 19 +- .../components/richtext/KRRichTextView.cpp | 20 +- .../components/scroller/KRScrollerView.cpp | 36 ++- .../components/scroller/KRScrollerView.h | 9 +- 9 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 BugFix/compose-all-sample-ohos-scroll-white-screen.md create mode 100644 compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt diff --git a/BugFix/compose-all-sample-ohos-scroll-white-screen.md b/BugFix/compose-all-sample-ohos-scroll-white-screen.md new file mode 100644 index 000000000..ad368ceee --- /dev/null +++ b/BugFix/compose-all-sample-ohos-scroll-white-screen.md @@ -0,0 +1,266 @@ +# ComposeAllSample 鸿蒙滑动白屏优化总结 + +> 场景:鸿蒙设备上进入 `ComposeAllSample`(Demo案例-Compose语法),快速/慢速滑动 LazyColumn 时出现视口内大块空白(白屏)。 +> +> 状态:**框架层优化已验收**;`ComposeAllSample.kt` **保持 main 原版未改**,流畅度仍明显改善(说明收益主要来自框架,而非 Demo 页减负)。 + +--- + +## 1. 问题本质 + +Kuikly Compose 的 `LazyColumn` **不是** ArkUI 原生 List,而是 **双引擎滚动**: + +``` +ArkUI ScrollerView 位移 + → Native onScroll 桥接到 Kotlin + → SubcomposeLayout 同步 composeOffset + → LazyListState.kuiklyOnScroll → remeasure / subcompose 新 item + → 创建/更新原生 DivView + Text(KRRichTextView 绘制) +``` + +白屏不是 crash,而是 **滚动链路过长 + 回调过频**,新 item 来不及绘制,视口短暂露出背景色 `#F5F5F5`。 + +`KuiklyScrollTrace` 日志证实:瓶颈在 **每次 onScroll 都触发 calcSize / expand / 桥接**(一次 fling 可达 300+ 次 Kuikly 业务回调),而非 hilog 打印本身。 + +--- + +## 2. 修改项生效度排名 + +按 **对流畅度 / 白屏的实测贡献** 排序(★★★★★ 最高)。排名依据:`KuiklyScrollTrace` 前后对比 + 恢复 `ComposeAllSample.kt` 后仍可流畅的交叉验证。 + +| 排名 | 生效度 | 修改项 | 文件 | 日志 / 现象依据 | +|:---:|--------|--------|------|-----------------| +| **#1** | ★★★★★ | **expand 空转去除**:`tryExpandStartSize` 仅在双端 offset 真正不同步时执行 | `ContentSizeExtensions.kt` | `expand` 总量 **944 → 0**;此前 `kuiklyScroll=0` 时仍 expand 36 次/手势 | +| **#2** | ★★★★★ | **calc/expand 与 `kuiklyOnScroll` 绑定**:只有 LazyList 真实滚动后才 calc + expand | `SubcomposeLayout.kt` | 快滑 `calcSize` **170 → 38**(`kuiklyScroll=14~37`) | +| **#3** | ★★★★☆ | **contentSize 去重**:`lastAppliedContentSize` 避免重复 `setFrame` | `KuiklyScrollInfo.kt` | `dedup` 数百次 vs `setFrame` 个位数;抑制原生 contentView relayout 风暴 | +| **#4** | ★★★★☆ | **RichText 排版就绪时继续绘制**:主线程任务中 typography 已就绪则不 Skip | `KRRichTextView.cpp` | `OnForegroundDraw Skip` **恒为 0**;直接消除新 item 文字白块 | +| **#5** | ★★★★☆ | **滚动中 calc 节流**:`calculateAndUpdateContentSizeIfNeeded` 仅近底 / 未知真实高度时更新 | `ContentSizeExtensions.kt` | 长滑中间段不再每帧读 frame;`calc/scroll` **1.57x → 1.13x** | +| **#6** | ★★★☆☆ | **Fling 态 Native 量化 2vp**:快滑加大位移阈值 | `KRScrollerView.cpp` | `fireToBridge` **173 → 90**(同场景快滑);`fireSkipped` 提升至 ~23–35% | +| **#7** | ★★★☆☆ | **Compose sub-pixel 过滤**:`< 0.5px` 位移累积,不驱动 remeasure | `SubcomposeLayout.kt` | 与 LazyListState 对齐;挡鸿蒙高频小数 onScroll | +| **#8** | ★★★☆☆ | **触底边界 defer**:`pendingBottomExpand` 标记,scrollEnd 统一扩容 | `SubcomposeLayout.kt` + `KuiklyScrollInfo.kt` | 消除 `toButtomDelta<=0` 每帧 calc(earlyRet 手势中 calc 虚高主因) | +| **#9** | ★★★☆☆ | **scrollEnd 统一收尾**:`finalizeNativeScrollSync` 一次 calc + offset 校正 | `SubcomposeLayout.kt` + `ContentSizeExtensions.kt` | 保证手势结束双端 offset / contentSize 最终一致 | +| **#10** | ★★☆☆☆ | **慢拖 Native 量化 0.5vp** + scrollStop `force` flush | `KRScrollerView.cpp` | 慢滑仍 ~1:1 桥接,但消除 sub-pixel 噪声;stop 时补齐尾差 | +| **#11** | ★★☆☆☆ | **语义树 debounce + 无障碍关闭时跳过** | `RootNodeOwner.kt` | 滚动中少遍历语义树;Demo 默认 `debugUIInspector=true` 时收益有限 | +| **#12** | ★☆☆☆☆ | **OHOS expand delay 缩短**(25→16ms,settle 150→80ms) | `ContentSizeExtensions.kt` | 停手后空白窗口略缩短;难单独量化 | +| — | (诊断) | `KuiklyScrollTrace` 分层计数 | `KuiklyScrollTrace.kt` | 非性能优化;`ENABLED=false` 默认关闭 | +| — | (未采用) | **ComposeAllSample 页面减负** | `ComposeAllSample.kt` | 见 §3;**未合入**,恢复 main 后仍流畅 | + +### 生效度分级说明 + +| 等级 | 含义 | +|------|------| +| ★★★★★ | 日志有数量级变化,或直接导致白块消失;**必须合入** | +| ★★★★☆ | 显著减少重操作 / 原生 relayout;**强烈建议合入** | +| ★★★☆☆ | 明显减少回调或边界 case 浪费;**建议合入** | +| ★★☆☆☆ | 有收益但难单独量化,或仅特定场景;**可合入** | +| ★☆☆☆☆ | 边际优化;**可选** | +| 未采用 | 业务页可选实践,**非框架必需**(本次验证已排除) | + +--- + +## 3. 已合入修改详情(按排名) + +### #1–#2 滚动同步:「只在真正滚动时做重活」 + +**优化前**:每次 Native `onScroll`(~60fps)都执行 `calculateAndUpdateContentSize` + `tryExpandStartSize` + 可能 `kuiklyOnScroll`。 + +**优化后**: + +``` +Native onScroll + ├─ [L1] 位移量化(0.5vp / fling 2vp) → 减桥接 + ├─ [L2] Compose sub-pixel 过滤(< 0.5px) → 减 remeasure + ├─ [L3] earlyReturn(顶边界 / ignoreOffset) → 不驱动 LazyList + └─ [L4] kuiklyOnScroll 成功后 + ├─ calculateAndUpdateContentSizeIfNeeded() + └─ tryExpandStartSize()(#1 条件守卫) +scrollEnd → finalizeNativeScrollSync() → 一次收尾 +``` + +```kotlin +// SubcomposeLayout.kt — 仅真实滚动后同步 +scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) +scrollableState.calculateAndUpdateContentSizeIfNeeded() +scrollableState.tryExpandStartSize(offset, true) +``` + +```kotlin +// ContentSizeExtensions.kt — expand 空转去除(#1) +val needsTopExpand = offset <= 0 && !atTopSync && kuiklyInfo.offsetDirty +val needsScrollViewPullBack = offset > 0 && atTopSync +if (!needsTopExpand && !needsScrollViewPullBack) return +``` + +--- + +### #3 contentSize 去重(`KuiklyScrollInfo.kt`) + +```kotlin +private var lastAppliedContentSize: Int = -1 + +fun updateContentSizeToRender() { + if (currentContentSize == lastAppliedContentSize) return + lastAppliedContentSize = currentContentSize + scrollView?.contentView?.setFrameToRenderView(createContentFrame()) +} +``` + +--- + +### #4 RichText 绘制(`KRRichTextView.cpp`) + +```cpp +if (rootView->IsPerformMainTasking()) { + if (richTextShadow == nullptr || richTextShadow->MainThreadTypographyHandle() == nullptr) { + // 排版未就绪 → 下一帧 markDirty + return; + } + // typography 就绪 → 继续绘制,不 Skip +} +``` + +--- + +### #5–#9 calc 节流与 scrollEnd 收尾(`ContentSizeExtensions.kt`) + +```kotlin +internal fun ScrollableState.calculateAndUpdateContentSizeIfNeeded(force: Boolean = false) { + if (force || kuiklyInfo.nearScrollBottom() || kuiklyInfo.realContentSize == null) { + calculateAndUpdateContentSize() + } +} + +internal fun ScrollableState.finalizeNativeScrollSync(offset: Int) { + calculateAndUpdateContentSize() + if (kuiklyInfo.pendingBottomExpand) { + kuiklyInfo.pendingBottomExpand = false + } + tryExpandStartSize(offset, isScrolling = false) +} +``` + +```kotlin +// SubcomposeLayout.kt — 触底 defer(#8) +if (toButtomDelta.toInt() <= 0) { + kuiklyInfo.pendingBottomExpand = true + return@scroll +} +``` + +--- + +### #6–#10 Native 滚动量化(`KRScrollerView.cpp`) + +```cpp +constexpr float kMinScrollOffsetDelta = 0.5f; +constexpr float kFlingScrollOffsetDelta = 2.0f; +const float minDelta = (current_scroll_state_ == ARKUI_SCROLL_STATE_FLING) + ? kFlingScrollOffsetDelta + : kMinScrollOffsetDelta; +``` + +`OnScrollStop` 时 `FireOnScrollEvent(event, true)` 强制 flush 最终 offset。 + +--- + +### #11 语义树(`RootNodeOwner.kt`) + +```kotlin +override fun onSemanticsChange() { + if (!isSemanticsRunnnng) return + semanticsDebounceJob?.cancel() + semanticsDebounceJob = semanticsCoroutineScope.launch { + delay(100) + semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) + } +} +``` + +--- + +## 4. 未采用的页面层优化(可选参考) + +以下改动曾验证有效,但 **`ComposeAllSample.kt` 已恢复 `main` 原版**,未纳入最终合入范围。业务列表可参考,**非白屏根因修复**。 + +| 项 | 改法 | 预估生效度 | 说明 | +|----|------|-----------|------| +| 关 UI Inspector | `debugUIInspector()` 默认 `false` | ★★☆☆☆ | 减调试 overlay;Demo 当前仍为 `true` | +| stable key | `items(..., key = { it.pageName })` | ★★☆☆☆ | 减 slot 重建 | +| 去 Card shadow | `Row + background` 替代 `Card` | ★★☆☆☆ | 减离屏阴影 | +| 固定 item 高度 | `.height(72.dp)` | ★★★☆☆ | 提升 `noRemeasure` 比例;对慢滑白屏有帮助 | + +--- + +## 5. 日志验收数据 + +诊断:`KuiklyScrollTrace`(`ENABLED=true`,`hilog | grep KuiklyScrollTrace`) + +### 5.1 框架优化前后(ComposeAllSample 有页面改动时期) + +| 指标 | 优化前(18 次手势) | 优化后(15 次手势) | +|------|---------------------|---------------------| +| expand 总量 | **944** | **0** | +| calc / kuiklyScroll | 1.57x | 1.13x | + +### 5.2 典型快速 fling + +| 指标 | 优化前 | 框架优化后 | +|------|--------|------------| +| fireToBridge | 173 | 90 | +| kuiklyScroll | 14 | 37 | +| calcSize | **170** | **38** | +| expand | **170** | **0** | + +### 5.3 恒成立项 + +- `OnForegroundDraw Skip`:**0** +- `setFrame` 极少,`dedup` 占绝大多数 +- 恢复 `ComposeAllSample.kt` 后:**仍流畅** → 排名 #1–#11 框架改动可独立生效 + +--- + +## 6. 已合入文件清单 + +``` +compose/.../SubcomposeLayout.kt # #2 #7 #8 #9 +compose/.../ContentSizeExtensions.kt # #1 #5 #9 #12 +compose/.../KuiklyScrollInfo.kt # #3 #8 +compose/.../RootNodeOwner.kt # #11 +compose/.../KuiklyScrollTrace.kt # 诊断(默认关) +core-render-ohos/.../KRScrollerView.cpp/.h # #6 #10 +core-render-ohos/.../KRRichTextView.cpp # #4 +``` + +**未修改**:`demo/.../ComposeAllSample.kt`(保持 `main`) + +--- + +## 7. 可复用经验 + +1. **先查「同步频率」再查「单帧绘制」**:双引擎列表的白屏多为回调风暴,不是 GPU 慢。 +2. **用分层计数定位空转**:`fireToBridge` / `kuiklyScroll` / `calc` / `expand` / `remeasure` 分开统计。 +3. **去重 > 节流 > 延后**:`lastAppliedContentSize`(#3)成本低收益高;calc 绑定滚动(#2)次之;scrollEnd 收尾(#9)保底一致性。 +4. **框架优化可独立于业务页**:本次恢复 Demo 原版后仍流畅,说明 #1–#11 是通用收益。 +5. **业务页优化(§4)是锦上添花**:固定高度、stable key 对慢滑 remeasure 仍有价值,但不替代框架改动。 + +--- + +## 8. 后续可选 + +| 优先级 | 方向 | 关联排名 | +|--------|------|----------| +| 中 | 慢滑 remeasure 根因(item 高度稳定性) | 对标 §4 固定高度 | +| 中 | 语义同步全局开关(列表页默认关) | #11 增强 | +| 低 | iOS / Android 对齐 fling 2vp 策略 | #6 跨端 | +| 低 | MR 拆分:仅框架层一个 PR | — | + +--- + +## 9. 复现与验证 + +**进入 ComposeAllSample(鸿蒙)**: + +1. 冷启动 App → 「Kuikly页面路由」 +2. 点击 **「Demo案例-Compose语法」**(须 `router.pushUrl`,勿用 `aa start --ps pageName` 冷启动) + +**验收**:S1 快速 fling ×3、S2 匀速滑、S3 边界来回、S4 静止后 fling;视口无大块空白,`OnForegroundDraw Skip = 0`。 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 7988cd0c6..e205bd9af 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -149,6 +149,11 @@ class KuiklyScrollInfo { */ var cachedTotalItems: Int = 0 + /** + * 滚动中触及底部边界(toButtomDelta<=0)时置位,scrollEnd 时统一扩容 contentSize。 + */ + var pendingBottomExpand: Boolean = false + /** * Sticky Header Position Cache Manager */ @@ -164,7 +169,15 @@ class KuiklyScrollInfo { /** * Update content size to render view */ + private var lastAppliedContentSize: Int = -1 + fun updateContentSizeToRender() { + if (currentContentSize == lastAppliedContentSize) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeDeduped++ } + return + } + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeToRender++ } + lastAppliedContentSize = currentContentSize val frame = createContentFrame() scrollView?.contentView?.setFrameToRenderView(frame) } @@ -194,6 +207,8 @@ class KuiklyScrollInfo { stickyItemKey = null cachedTotalItems = 0 pullToRefreshTopInsetPx = 0 + lastAppliedContentSize = -1 + pendingBottomExpand = false } /** diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt new file mode 100644 index 000000000..ac9a29106 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt @@ -0,0 +1,68 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.gestures + +/** + * 滚动链路诊断:统计一次手势内各层调用次数,在 scrollEnd 时汇总打印。 + * 过滤:hilog | grep KuiklyScrollTrace + */ +internal object KuiklyScrollTrace { + /** 调试时设为 true;发布前保持 false */ + const val ENABLED = false + + private const val TAG = "KuiklyScrollTrace" + + var composeScrollReceived = 0 + var composeDeltaFiltered = 0 + var composeEarlyReturn = 0 + var calculateContentSize = 0 + var kuiklyOnScroll = 0 + var tryExpandStartSize = 0 + var contentSizeToRender = 0 + var contentSizeDeduped = 0 + var lazyRemeasure = 0 + var lazyScrollWithoutRemeasure = 0 + + inline fun ifEnabled(block: () -> Unit) { + if (ENABLED) block() + } + + fun reset() { + composeScrollReceived = 0 + composeDeltaFiltered = 0 + composeEarlyReturn = 0 + calculateContentSize = 0 + kuiklyOnScroll = 0 + tryExpandStartSize = 0 + contentSizeToRender = 0 + contentSizeDeduped = 0 + lazyRemeasure = 0 + lazyScrollWithoutRemeasure = 0 + } + + fun dumpSummary(phase: String) { + if (!ENABLED) return + println( + "[$TAG] $phase | " + + "composeIn=$composeScrollReceived " + + "filtered=$composeDeltaFiltered earlyRet=$composeEarlyReturn " + + "calcSize=$calculateContentSize kuiklyScroll=$kuiklyOnScroll expand=$tryExpandStartSize " + + "setFrame=$contentSizeToRender dedup=$contentSizeDeduped " + + "remeasure=$lazyRemeasure noRemeasure=$lazyScrollWithoutRemeasure " + + "(native fireToBridge see hilog KuiklyScrollTrace)" + ) + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 0ef9457f6..c8d4edc19 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -15,6 +15,7 @@ package com.tencent.kuikly.compose.scroller +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.foundation.ScrollState import com.tencent.kuikly.compose.foundation.gestures.Orientation import com.tencent.kuikly.compose.foundation.gestures.ScrollableState @@ -72,6 +73,7 @@ internal fun ScrollableState.calculateContentSize(): Int { } internal fun ScrollableState.calculateAndUpdateContentSize() { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.calculateContentSize++ } // 更新当前的contentSize大小 val oldContentSize = kuiklyInfo.currentContentSize val newContentSize = calculateContentSize() @@ -92,6 +94,27 @@ internal fun ScrollableState.calculateAndUpdateContentSize() { kuiklyInfo.updateContentSizeToRender() } +/** + * 滚动过程中仅在接近底部或尚未得到真实 contentSize 时更新 native contentSize。 + * [force] 用于 scrollEnd 等必须同步的时机。 + */ +internal fun ScrollableState.calculateAndUpdateContentSizeIfNeeded(force: Boolean = false) { + if (force || kuiklyInfo.nearScrollBottom() || kuiklyInfo.realContentSize == null) { + calculateAndUpdateContentSize() + } +} + +/** + * 一次手势结束后的 native 滚动同步:contentSize + offset 校正 + 底部扩容。 + */ +internal fun ScrollableState.finalizeNativeScrollSync(offset: Int) { + calculateAndUpdateContentSize() + if (kuiklyInfo.pendingBottomExpand) { + kuiklyInfo.pendingBottomExpand = false + } + tryExpandStartSize(offset, isScrolling = false) +} + internal fun PaddingValues.totalPadding(orientation: Orientation): Dp { return if (orientation == Orientation.Vertical) { calculateTopPadding() + calculateBottomPadding() @@ -254,9 +277,18 @@ internal fun ScrollableState.calculateBackExpandSize(offset: Int): Int? { internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolean) { if (kuiklyInfo.scrollView == null) return + val atTopSync = isComposeAtTopForScrollSync() + val needsTopExpand = offset <= 0 && !atTopSync && kuiklyInfo.offsetDirty + val needsScrollViewPullBack = offset > 0 && atTopSync + if (!needsTopExpand && !needsScrollViewPullBack) { + return + } + + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.tryExpandStartSize++ } + val density = kuiklyInfo.getDensity() // scrollview 到顶了,但是compose没到顶 - if (offset <= 0 && !isComposeAtTopForScrollSync() && kuiklyInfo.offsetDirty) { + if (needsTopExpand) { var delta = calculateBackExpandSize(offset) val minDelta = (ScrollableStateConstants.DEFAULT_CONTENT_SIZE * density).toInt() delta = max(delta ?: minDelta, minDelta) @@ -276,7 +308,7 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea } kuiklyInfo.offsetDirty = true applyScrollViewOffsetDelta(delta) - } else if (offset > 0 && isComposeAtTopForScrollSync()) { + } else if (needsScrollViewPullBack) { // compose 到顶了,但是scrollview没到顶 applyScrollViewOffsetDelta(-offset) kuiklyInfo.offsetDirty = false @@ -288,7 +320,8 @@ internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = f kuiklyInfo.run { appleScrollViewOffsetJob?.cancel() appleScrollViewOffsetJob = scope?.launch { - delay(150) + val settleDelay = if (pageData?.isOhOs == true) 80 else 150 + delay(settleDelay.toLong()) val minDelta = (DEFAULT_CONTENT_SIZE * getDensity()).toInt() val epsilon = 0.5 * getDensity() // 使用 0.5dp 作为误差值 val reachBtm = contentOffset + viewportSize - currentContentSize >= -epsilon @@ -304,7 +337,7 @@ internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = f updateContentSizeToRender() } if (pageData?.isOhOs == true) { - delay(25) // 鸿蒙扩容后,不会立刻刷新,也没有刷新api,华为建议添加一个delay来处理 + delay(16) } applyScrollViewOffsetDelta(delta) offsetDirty = true diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 6a9426fd3..4f55aa432 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -64,7 +64,9 @@ import com.tencent.kuikly.compose.ui.platform.createSubcomposition import com.tencent.kuikly.compose.ui.unit.Constraints import com.tencent.kuikly.compose.ui.unit.LayoutDirection import com.tencent.kuikly.compose.ui.util.fastForEach +import com.tencent.kuikly.compose.ui.util.fastRoundToInt import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.views.KuiklyInfoKey import com.tencent.kuikly.compose.views.VirtualNodeView import com.tencent.kuikly.compose.layout.bindKuiklyInfo @@ -90,6 +92,8 @@ import com.tencent.kuikly.core.views.ScrollerEvent import com.tencent.kuikly.core.views.ScrollerView import com.tencent.kuikly.compose.scroller.animateScrollToTop import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSize +import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSizeIfNeeded +import com.tencent.kuikly.compose.scroller.finalizeNativeScrollSync import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @@ -287,8 +291,10 @@ fun SubcomposeLayout( (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) // 仅触摸滑动结束会回调,api调用和bounce回弹都不会触发 - // / back是回滑,forward是前滑 + scrollableState.finalizeNativeScrollSync(offset) scrollableState.kuiklyOnScrollEnd(scaleParams) + KuiklyScrollTrace.dumpSummary("scrollEnd") + KuiklyScrollTrace.reset() } dragEnd { val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) @@ -297,8 +303,10 @@ fun SubcomposeLayout( kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false } scroll { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeScrollReceived++ } val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) - val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() + val nativeOffset = if (isVertical) scaleParams.offsetY else scaleParams.offsetX + val offset = nativeOffset.fastRoundToInt() val prevOffset = kuiklyInfo.contentOffset kuiklyInfo.contentOffset = offset @@ -313,17 +321,21 @@ fun SubcomposeLayout( if (matched) { kuiklyInfo.ignoreScrollOffset = null } + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll } - // 忽略较小的滑动 - val delta = offset - kuiklyInfo.composeOffset - if (delta.toInt() == 0) { + // 与 LazyListState 一致:不足 0.5px 的位移先累积,避免鸿蒙高频 sub-pixel onScroll 触发 remeasure + val delta = nativeOffset - kuiklyInfo.composeOffset + if (abs(delta) < 0.5f) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeDeltaFiltered++ } + return@scroll + } + val scrollDelta = delta.fastRoundToInt() + if (scrollDelta == 0) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll } - - // 更新当前的contentSize大小 - scrollableState.calculateAndUpdateContentSize() val toButtomDelta = if (kuiklyInfo.realContentSize == null) { null @@ -332,21 +344,23 @@ fun SubcomposeLayout( } // 判断是否滑出边界 if (offset < 0 && scrollableState.isAtTop()) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll - } else if (toButtomDelta != null && delta > toButtomDelta) { + } else if (toButtomDelta != null && scrollDelta > toButtomDelta) { if (toButtomDelta.toInt() <= 0) { - scrollableState.tryExpandStartSize(offset, true) + kuiklyInfo.pendingBottomExpand = true + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll } - kuiklyInfo.composeOffset += min(delta, toButtomDelta) + kuiklyInfo.composeOffset += min(scrollDelta.toFloat(), toButtomDelta) } else { - kuiklyInfo.composeOffset = max(0f, kuiklyInfo.composeOffset + delta) + kuiklyInfo.composeOffset = max(0f, kuiklyInfo.composeOffset + scrollDelta) } - // 触发compose滑动,并重新布局 - val comsumedDelta = scrollableState.kuiklyOnScroll(delta) - - // 尝试扩容 + // 仅在实际驱动 LazyList 滚动后同步 contentSize / offset 校正 + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.kuiklyOnScroll++ } + scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) + scrollableState.calculateAndUpdateContentSizeIfNeeded() scrollableState.tryExpandStartSize(offset, true) } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt index d6e1909cf..a553fd0cf 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt @@ -58,6 +58,10 @@ import com.tencent.kuikly.compose.ui.util.fastAll import com.tencent.kuikly.compose.profiler.RecompositionProfiler import com.tencent.kuikly.core.base.DeclarativeBaseView import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Owner of root [LayoutNode]. @@ -119,6 +123,9 @@ internal class RootNodeOwner( // // (which is what we want). // isTraversalGroup = true // } + private val semanticsCoroutineScope = CoroutineScope(coroutineContext) + private var semanticsDebounceJob: Job? = null + val owner: Owner = OwnerImpl(layoutDirection, coroutineContext, rootKView, density) val semanticsOwner = SemanticsOwner(owner.root) private val semanticsKuiklyHandler = KuiklySemantisHandler() @@ -154,6 +161,8 @@ internal class RootNodeOwner( fun dispose() { check(!isDisposed) { "RootNodeOwner is already disposed" } + semanticsDebounceJob?.cancel() + semanticsDebounceJob = null // platformContext.rootForTestListener?.onRootForTestDisposed(rootForTest) snapshotObserver.stopObserving() // graphicsContext.dispose() @@ -399,8 +408,14 @@ internal class RootNodeOwner( ) override fun onSemanticsChange() { -// platformContext.semanticsOwnerListener?.onSemanticsChange(semanticsOwner) - semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) + if (!isSemanticsRunnnng) { + return + } + semanticsDebounceJob?.cancel() + semanticsDebounceJob = semanticsCoroutineScope.launch { + delay(100) + semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) + } } override fun onZIndexChange(layoutNode: LayoutNode) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index 210453a2b..ebb50c983 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -164,16 +164,20 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { } if (auto rootView = GetRootView().lock()) { if (rootView->IsPerformMainTasking()) { - std::weak_ptr weakSelf = shared_from_this(); - KRMainThread::RunOnMainThreadForNextLoop([weakSelf] { - if(auto strongSelf = weakSelf.lock()){ - kuikly::util::GetNodeApi()->markDirty(strongSelf->GetNode(), NODE_NEED_RENDER); - } - }); + auto richTextShadow = reinterpret_cast(shadow_.get()); + // typography 已就绪时同步绘制,避免滚动中新 item 白块;未就绪则下一帧 markDirty + if (richTextShadow == nullptr || richTextShadow->MainThreadTypographyHandle() == nullptr) { + std::weak_ptr weakSelf = shared_from_this(); + KRMainThread::RunOnMainThreadForNextLoop([weakSelf] { + if (auto strongSelf = weakSelf.lock()) { + kuikly::util::GetNodeApi()->markDirty(strongSelf->GetNode(), NODE_NEED_RENDER); + } + }); #ifndef NDEBUG - KR_LOG_ERROR << "OnForegroundDraw, IsPerformMainTasking Skip:" << shadow_.get(); + KR_LOG_ERROR << "OnForegroundDraw, IsPerformMainTasking Skip:" << shadow_.get(); #endif - return; + return; + } } } auto richTextShadow = reinterpret_cast(shadow_.get()); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp index 4a3a57322..5199ac473 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp @@ -17,6 +17,7 @@ #include #include +#include "libohos_render/utils/KRRenderLoger.h" #include "libohos_render/expand/components/view/KRView.h" #include "libohos_render/foundation/type/KRRenderValue.h" #include "libohos_render/utils/KRJSONObject.h" @@ -234,6 +235,7 @@ void KRScrollerView::CallMethod(const std::string &method, const KRAnyValue &par void KRScrollerView::OnEvent(ArkUI_NodeEvent *event, const ArkUI_NodeEventType &event_type) { if (event_type == NODE_SCROLL_EVENT_ON_SCROLL) { + trace_ark_on_scroll_++; FireOnScrollEvent(event); } else if (event_type == NODE_SCROLL_EVENT_ON_SCROLL_FRAME_BEGIN) { OnScrollFrameBegin(event); @@ -248,9 +250,18 @@ void KRScrollerView::OnEvent(ArkUI_NodeEvent *event, const ArkUI_NodeEventType & } } -void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event) { +void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event, bool force) { auto point = kuikly::util::GetArkUIScrollContentOffset(GetNode()); - if (point.x == last_fired_scroll_x_ && point.y == last_fired_scroll_y_) { + // ArkUI reports sub-pixel offsets every frame; quantize to avoid excessive bridge callbacks. + constexpr float kMinScrollOffsetDelta = 0.5f; + constexpr float kFlingScrollOffsetDelta = 2.0f; + const float minDelta = (current_scroll_state_ == ArkUI_ScrollState::ARKUI_SCROLL_STATE_FLING) + ? kFlingScrollOffsetDelta + : kMinScrollOffsetDelta; + if (!force && + fabsf(point.x - last_fired_scroll_x_) < minDelta && + fabsf(point.y - last_fired_scroll_y_) < minDelta) { + trace_fire_skipped_++; return; } last_fired_scroll_x_ = point.x; @@ -260,6 +271,7 @@ void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event) { if (!on_scroll_callback_) { return; } + trace_fire_to_bridge_++; on_scroll_callback_(GetCommonScrollParams()); } @@ -559,7 +571,13 @@ void KRScrollerView::OnScrollStop(ArkUI_NodeEvent *event) { if (is_dragging_) { OnWillDragEnd(event); } + // Flush the final offset so the Compose bridge can sync any sub-threshold remainder. + FireOnScrollEvent(event, true); FireEndScrollEvent(event); + DumpScrollTrace("scrollStop"); + trace_ark_on_scroll_ = 0; + trace_fire_skipped_ = 0; + trace_fire_to_bridge_ = 0; if (auto handler = weak_super_touch_handler_.lock()) { handler->ClearNativeTouchConsumer(shared_from_this()); } @@ -755,8 +773,20 @@ bool KRScrollerView::SetFlingEnable(bool enable) { return true; } +void KRScrollerView::DumpScrollTrace(const char *phase) { +#ifndef NDEBUG + if (trace_ark_on_scroll_ == 0 && trace_fire_to_bridge_ == 0) { + return; + } + KR_LOG_INFO_WITH_TAG("KuiklyScrollTrace") + << phase << " | arkOnScroll=" << trace_ark_on_scroll_ + << " fireSkipped=" << trace_fire_skipped_ + << " fireToBridge=" << trace_fire_to_bridge_; +#endif +} + void KRScrollerView::TryApplyPendingFireOnScroll() { - FireOnScrollEvent(nullptr); + FireOnScrollEvent(nullptr, true); } // Clear transient native state for Compose DSL reuse (not the native reuse pool). diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h index 68e85b39a..1ea0d52c3 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h @@ -17,6 +17,7 @@ #define CORE_RENDER_OHOS_KRSCROLLERVIEW_H #include +#include #include "KRScrollerContentInset.h" #include "libohos_render/export/IKRRenderViewExport.h" #include "libohos_render/foundation/KRPoint.h" @@ -109,7 +110,7 @@ class KRScrollerView : public IKRRenderViewExport { bool RegisterOnDragEndEvent(const KRRenderCallback event_callback); bool RegisterOnScrollEndEvent(const KRRenderCallback event_callback); bool RegisterWillDragEndEvent(const KRRenderCallback event_callback); - void FireOnScrollEvent(ArkUI_NodeEvent *event); + void FireOnScrollEvent(ArkUI_NodeEvent *event, bool force = false); void FireBeginDragEvent(ArkUI_NodeEvent *event); void FireEndDragEvent(ArkUI_NodeEvent *event); void FireEndScrollEvent(ArkUI_NodeEvent *event); @@ -174,6 +175,12 @@ class KRScrollerView : public IKRRenderViewExport { float last_fired_scroll_x_ = 0; float last_fired_scroll_y_ = 0; bool direction_row_ = false; + + // Scroll trace (debug): counts per gesture, dumped on scroll stop + uint32_t trace_ark_on_scroll_ = 0; + uint32_t trace_fire_skipped_ = 0; + uint32_t trace_fire_to_bridge_ = 0; + void DumpScrollTrace(const char *phase); }; #endif // CORE_RENDER_OHOS_KRSCROLLERVIEW_H From df96ce25a915b67be03c82b74df8b58818f6ece4 Mon Sep 17 00:00:00 2001 From: zenipchen Date: Fri, 26 Jun 2026 12:57:30 +0800 Subject: [PATCH 023/187] fix(compose): suppress OHOS sub-pixel scroll remeasure storms Quantize ArkUI scroll callbacks and SubcomposeLayout deltas at 0.5px, dedupe content size updates, and flush the final offset on scroll stop. Add OHOS demo cold-start page params and a CanScrollForward repro page. Co-authored-by: Cursor --- .../pages/compose/BugReproCanScrollForward.kt | 138 ++++++++++++++++++ .../demo/pages/compose/ComposeAllSample.kt | 102 ++++++------- .../main/ets/entryability/EntryAbility.ets | 13 ++ ohosApp/entry/src/main/ets/pages/Index.ets | 25 +++- 4 files changed, 218 insertions(+), 60 deletions(-) create mode 100644 demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt new file mode 100644 index 000000000..fc2ebfb9c --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt @@ -0,0 +1,138 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.demo.pages.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.tencent.kuikly.compose.ComposeContainer +import com.tencent.kuikly.compose.foundation.background +import com.tencent.kuikly.compose.foundation.clickable +import com.tencent.kuikly.compose.foundation.layout.Arrangement +import com.tencent.kuikly.compose.foundation.layout.Box +import com.tencent.kuikly.compose.foundation.layout.Column +import com.tencent.kuikly.compose.foundation.layout.PaddingValues +import com.tencent.kuikly.compose.foundation.layout.Spacer +import com.tencent.kuikly.compose.foundation.layout.fillMaxSize +import com.tencent.kuikly.compose.foundation.layout.fillMaxWidth +import com.tencent.kuikly.compose.foundation.layout.height +import com.tencent.kuikly.compose.foundation.layout.offset +import com.tencent.kuikly.compose.foundation.layout.padding +import com.tencent.kuikly.compose.foundation.layout.size +import com.tencent.kuikly.compose.foundation.lazy.LazyColumn +import com.tencent.kuikly.compose.foundation.lazy.rememberLazyListState +import com.tencent.kuikly.compose.foundation.shape.CircleShape +import com.tencent.kuikly.compose.material3.Text +import com.tencent.kuikly.compose.setContent +import com.tencent.kuikly.compose.ui.Alignment +import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.draw.clip +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.unit.dp +import com.tencent.kuikly.core.annotations.Page + +@Page("5555") +internal class BugReproCanScrollForward : ComposeContainer() { + override fun willInit() { + super.willInit() + setContent { + CanScrollForwardBugDemo() + } + } +} + +@Composable +private fun CanScrollForwardBugDemo() { + val listState = rememberLazyListState() + var showFloatBall by remember { mutableStateOf(false) } + var canScrollForwardValue by remember { mutableStateOf(false) } + var lastScrolledBackwardValue by remember { mutableStateOf(false) } + + LaunchedEffect(listState) { + snapshotFlow { + listState.canScrollForward to listState.lastScrolledBackward + }.collect { (canFwd, scrolledBwd) -> + canScrollForwardValue = canFwd + lastScrolledBackwardValue = scrolledBwd + + if (!canFwd) { + showFloatBall = false + } else if (scrolledBwd) { + showFloatBall = true + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF333333)) + .padding(16.dp) + ) { + Text( + text = "canScrollForward: $canScrollForwardValue\nlastScrolledBackward: $lastScrolledBackwardValue\nshowFloatBall: $showFloatBall", + color = Color.White, + ) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 28.dp, end = 28.dp, top = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(50) { index -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .background(if (index % 2 == 0) Color(0xFFEEEEEE) else Color.White) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text(text = "Item $index") + } + } + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + + if (showFloatBall) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = (-16).dp, y = (-16).dp) + .size(48.dp) + .clip(CircleShape) + .background(Color.Blue) + .clickable { + // 点击回底 + }, + contentAlignment = Alignment.Center, + ) { + Text("↑", color = Color.White) + } + } + } +} diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt index 51ee9571e..0263c3549 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt @@ -16,10 +16,8 @@ package com.tencent.kuikly.demo.pages.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import com.tencent.kuikly.compose.ComposeContainer -import com.tencent.kuikly.compose.extension.scrollToTop import com.tencent.kuikly.compose.foundation.background import com.tencent.kuikly.compose.foundation.clickable import com.tencent.kuikly.compose.foundation.layout.Arrangement @@ -37,8 +35,6 @@ import com.tencent.kuikly.compose.foundation.layout.width import com.tencent.kuikly.compose.foundation.lazy.LazyColumn import com.tencent.kuikly.compose.foundation.lazy.items import com.tencent.kuikly.compose.foundation.shape.RoundedCornerShape -import com.tencent.kuikly.compose.material3.Card -import com.tencent.kuikly.compose.material3.CardDefaults import com.tencent.kuikly.compose.material3.Text import com.tencent.kuikly.compose.setContent import com.tencent.kuikly.compose.ui.Alignment @@ -64,7 +60,8 @@ internal data class DemoItem( @Page("ComposeAllSample") internal class ComposeAllSample : ComposeContainer() { - override fun debugUIInspector(): Boolean = true + // 性能基线默认关闭;自动化测试可通过 pageData `debug=1` 开启 inspector + override fun debugUIInspector(): Boolean = pageData.params.optBoolean("debug", false) // 预定义一组美观的Material Design颜色 private val demoColors = listOf( @@ -167,16 +164,14 @@ internal class ComposeAllSample : ComposeContainer() { DemoItem("GradientAnimationDemo", "Offset or color animate ", "GradientAnimationDemo"), DemoItem("重组性能分析", "RecompositionProfiler追踪重组热点", "RecompositionProfilerDemo"), DemoItem("TextFieldEmoji", "TextField 自定义表情示例(暂不支持鸿蒙)", "TextFieldEmojiDemo"), + // Bug Repro + DemoItem("CanScrollForward", "LazyColumn canScrollForward 浮球 repro", "5555"), + DemoItem("LazyColumnImageWhite", "LazyColumn 网络图回滚白图 repro (Android10)", "ccl"), + DemoItem("BottomSheetDrag", "BottomSheet 拖动手势 repro", "BottomSheetDragDemo"), ) @Composable fun DemoListScreen() { - - LaunchedEffect(Unit) { - println("DemoListScreen ") - } - - // 使用抽离出的函数获取演示列表 val demoList = remember { getDemoItems() } Column( @@ -191,7 +186,10 @@ internal class ComposeAllSample : ComposeContainer() { verticalArrangement = Arrangement.spacedBy(8.dp), // 减小间距 contentPadding = PaddingValues(all = 8.dp), ) { - items(demoList) { demo -> + items( + items = demoList, + key = { it.pageName }, + ) { demo -> DemoItemCard(demo) { navigateToPage(demo) } @@ -205,59 +203,47 @@ internal class ComposeAllSample : ComposeContainer() { demo: DemoItem, onClick: () -> Unit, ) { - Card( + val iconColor = remember(demo.pageName) { getColorForDemo(demo.pageName) } + Row( modifier = Modifier .fillMaxWidth() - .testTag("demo_card_${demo.pageName}") - .clickable(onClick = onClick), - shape = RoundedCornerShape(8.dp), - colors = - CardDefaults.cardColors( - containerColor = Color.White, - ), - elevation = - CardDefaults.cardElevation( - defaultElevation = 2.dp, - ), + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .clickable(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(6.dp)) + .background(iconColor), + contentAlignment = Alignment.Center, ) { - // 左侧图标指示器 - Box( - modifier = - Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)) - .background(getColorForDemo(demo.pageName)), - contentAlignment = Alignment.Center, - ) { - Text( - text = demo.title.first().toString(), - color = Color.White, - fontWeight = FontWeight.Bold, - ) - } + Text( + text = demo.title.first().toString(), + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) - // 右侧文本内容 - Column { - Text( - demo.title, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF333333), - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - demo.description, - fontSize = 12.sp, - color = Color(0xFF666666), - ) - } + Column { + Text( + demo.title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF333333), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + demo.description, + fontSize = 12.sp, + color = Color(0xFF666666), + ) } } } diff --git a/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets b/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets index f48bf6a17..2952e66eb 100644 --- a/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets +++ b/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets @@ -22,9 +22,22 @@ import fs from '@ohos.file.fs'; import { BusinessError } from '@kit.BasicServicesKit'; import Napi from 'libkuikly_entry.so'; +const LAUNCH_PARAMS_KEY = 'kuiklyLaunchParams'; + export default class EntryAbility extends UIAbility { + private storeLaunchParams(want: Want): void { + if (want.parameters && Object.keys(want.parameters).length > 0) { + AppStorage.setOrCreate(LAUNCH_PARAMS_KEY, want.parameters); + } + } + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); + this.storeLaunchParams(want); + } + + onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { + this.storeLaunchParams(want); } onDestroy(): void { diff --git a/ohosApp/entry/src/main/ets/pages/Index.ets b/ohosApp/entry/src/main/ets/pages/Index.ets index 4ff1ea5fd..76f0bf281 100644 --- a/ohosApp/entry/src/main/ets/pages/Index.ets +++ b/ohosApp/entry/src/main/ets/pages/Index.ets @@ -23,6 +23,20 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { ContextCodeHandler } from '../kuikly/ContextCodeHandler'; import { AppKRRenderManager } from '../kuikly/adapters/AppKRRenderManager'; +function parsePageData(raw: Object | undefined): KRRecord { + if (raw == null) { + return {}; + } + if (typeof raw === 'string') { + try { + return JSON.parse(raw) as KRRecord; + } catch (_e) { + return {}; + } + } + return raw as KRRecord; +} + @Entry @Component struct Index { @@ -49,9 +63,16 @@ struct Index { aboutToAppear(): void { AppKRRenderManager.getInstance().initIfNeed(); - const params = router.getParams() as Record; + const routerParams = router.getParams() as Record; + const launchParams = AppStorage.get>('kuiklyLaunchParams'); + const params = (routerParams && Object.keys(routerParams).length > 0) + ? routerParams + : (launchParams ?? {}); + if (launchParams) { + AppStorage.delete('kuiklyLaunchParams'); + } this.pageName = params?.pageName as string; - this.pageData = (params?.pageData as KRRecord | null) ?? {}; + this.pageData = parsePageData(params?.pageData); if (this.contextCodeHandler.isNeedGetContextCode(params)) { this.contextCodeHandler.handleGetContextCode(getContext(), params, (contextCode) => { this.contextCode = contextCode; From 10a59cae89ba7de3a7f824155e580abbe1b15c46 Mon Sep 17 00:00:00 2001 From: zenipchen Date: Fri, 26 Jun 2026 15:48:46 +0800 Subject: [PATCH 024/187] demo(compose): expand ComposeAllSample for OHOS LazyColumn scroll stress test Add a 500-item repeated demo list with Card UI and inspector enabled to reproduce and profile HarmonyOS scroll performance locally. Co-authored-by: Cursor --- .../demo/pages/compose/ComposeAllSample.kt | 132 +++++++++++------- 1 file changed, 85 insertions(+), 47 deletions(-) diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt index 0263c3549..bfbef5995 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt @@ -16,6 +16,7 @@ package com.tencent.kuikly.demo.pages.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import com.tencent.kuikly.compose.ComposeContainer import com.tencent.kuikly.compose.foundation.background @@ -35,6 +36,8 @@ import com.tencent.kuikly.compose.foundation.layout.width import com.tencent.kuikly.compose.foundation.lazy.LazyColumn import com.tencent.kuikly.compose.foundation.lazy.items import com.tencent.kuikly.compose.foundation.shape.RoundedCornerShape +import com.tencent.kuikly.compose.material3.Card +import com.tencent.kuikly.compose.material3.CardDefaults import com.tencent.kuikly.compose.material3.Text import com.tencent.kuikly.compose.setContent import com.tencent.kuikly.compose.ui.Alignment @@ -60,8 +63,11 @@ internal data class DemoItem( @Page("ComposeAllSample") internal class ComposeAllSample : ComposeContainer() { - // 性能基线默认关闭;自动化测试可通过 pageData `debug=1` 开启 inspector - override fun debugUIInspector(): Boolean = pageData.params.optBoolean("debug", false) + override fun debugUIInspector(): Boolean = true + + /** 本地滚动压测用;恢复 main 时改回 1 即可 */ + private val demoListRepeatCount = 500 + // 预定义一组美观的Material Design颜色 private val demoColors = listOf( @@ -107,7 +113,6 @@ internal class ComposeAllSample : ComposeContainer() { DemoItem("焦点处理", "Focus焦点处理示例", "focusDemo"), DemoItem("TextField", "TextField 组件示例", "TextFieldDemo"), DemoItem("PullToRefresh", "PullToRefresh 组件示例", "PullToRefreshDemo"), - DemoItem("PTR Padding Bug", "Issue #1325 HeaderBar+PTR padding", "BugReproPullRefreshPaddingPage"), // 其他 DemoItem("封装KuiklyView", "封装Kuikly的VideoView为一个Composeable组件示例", "ComposeVideoDemo"), DemoItem("iOS LiquidGlass", "iOS LiquidGlass 组件示例", "LiquidGlassComposeDemo"), @@ -164,15 +169,30 @@ internal class ComposeAllSample : ComposeContainer() { DemoItem("GradientAnimationDemo", "Offset or color animate ", "GradientAnimationDemo"), DemoItem("重组性能分析", "RecompositionProfiler追踪重组热点", "RecompositionProfilerDemo"), DemoItem("TextFieldEmoji", "TextField 自定义表情示例(暂不支持鸿蒙)", "TextFieldEmojiDemo"), - // Bug Repro - DemoItem("CanScrollForward", "LazyColumn canScrollForward 浮球 repro", "5555"), - DemoItem("LazyColumnImageWhite", "LazyColumn 网络图回滚白图 repro (Android10)", "ccl"), - DemoItem("BottomSheetDrag", "BottomSheet 拖动手势 repro", "BottomSheetDragDemo"), ) @Composable fun DemoListScreen() { - val demoList = remember { getDemoItems() } + val demoList = + remember { + val base = getDemoItems() + List(demoListRepeatCount) { index -> + val source = base[index % base.size] + if (index < base.size) { + source + } else { + source.copy( + title = "${source.title} #${index + 1}", + description = "${source.description} (${index + 1}/$demoListRepeatCount)", + pageName = "${source.pageName}_$index", + ) + } + } + } + + LaunchedEffect(demoList.size) { + println("ComposeAllSample demoList size=${demoList.size}") + } Column( modifier = @@ -186,10 +206,16 @@ internal class ComposeAllSample : ComposeContainer() { verticalArrangement = Arrangement.spacedBy(8.dp), // 减小间距 contentPadding = PaddingValues(all = 8.dp), ) { - items( - items = demoList, - key = { it.pageName }, - ) { demo -> + item { + Text( + text = "压测列表:共 ${demoList.size} 条", + modifier = Modifier.fillMaxWidth().padding(8.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFE91E63), + ) + } + items(demoList) { demo -> DemoItemCard(demo) { navigateToPage(demo) } @@ -203,47 +229,59 @@ internal class ComposeAllSample : ComposeContainer() { demo: DemoItem, onClick: () -> Unit, ) { - val iconColor = remember(demo.pageName) { getColorForDemo(demo.pageName) } - Row( + Card( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(Color.White) - .clickable(onClick = onClick) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, + .testTag("demo_card_${demo.pageName}") + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + colors = + CardDefaults.cardColors( + containerColor = Color.White, + ), + elevation = + CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), ) { - Box( - modifier = - Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)) - .background(iconColor), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = demo.title.first().toString(), - color = Color.White, - fontWeight = FontWeight.Bold, - ) - } + // 左侧图标指示器 + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(6.dp)) + .background(getColorForDemo(demo.pageName)), + contentAlignment = Alignment.Center, + ) { + Text( + text = demo.title.first().toString(), + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - demo.title, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFF333333), - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - demo.description, - fontSize = 12.sp, - color = Color(0xFF666666), - ) + // 右侧文本内容 + Column { + Text( + demo.title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF333333), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + demo.description, + fontSize = 12.sp, + color = Color(0xFF666666), + ) + } } } } @@ -272,4 +310,4 @@ internal class ComposeAllSample : ComposeContainer() { @Composable fun NavBar(title: String) { -} \ No newline at end of file +} From da74e0a8172e7ecbe3d942915a297f1bb8d9b017 Mon Sep 17 00:00:00 2001 From: artin Date: Fri, 26 Jun 2026 17:42:41 +0800 Subject: [PATCH 025/187] fix(android): tighten inline code outer margin --- .../render/android/expand/component/text/KRRichTextBuilder.kt | 2 +- .../android/expand/component/text/KRRichTextViewDrawer.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index f6f94ef93..2e3b47af9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -57,7 +57,7 @@ import kotlin.math.ceil import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 7f / 15f -private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 3f / 15f +private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f /** * 富文本构造器 diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 1a5bff618..f7fd0dcda 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -38,7 +38,7 @@ private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f -private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 3f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f From c1285462c16b496a796bfb44d476a5691a5bf6d2 Mon Sep 17 00:00:00 2001 From: zenipchen Date: Mon, 29 Jun 2026 19:18:19 +0800 Subject: [PATCH 026/187] =?UTF-8?q?feat=EF=BC=9A=20opt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kuikly/compose/gestures/KuiklyScrollInfo.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index e205bd9af..7ddac0a2b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -170,14 +170,27 @@ class KuiklyScrollInfo { * Update content size to render view */ private var lastAppliedContentSize: Int = -1 + /** 与 contentSize 一并参与去重,避免 Android 上 ScrollView 宽度晚于首次 setFrame 时 contentView 宽度卡在 0 */ + private var lastAppliedViewportCrossSize: Float = -1f fun updateContentSizeToRender() { - if (currentContentSize == lastAppliedContentSize) { + val viewportCrossSize = if (isVertical()) { + scrollView?.renderView?.currentFrame?.width ?: 0f + } else { + scrollView?.renderView?.currentFrame?.height ?: 0f + } + if (viewportCrossSize <= 0f) { + return + } + if (currentContentSize == lastAppliedContentSize && + viewportCrossSize == lastAppliedViewportCrossSize + ) { KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeDeduped++ } return } KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeToRender++ } lastAppliedContentSize = currentContentSize + lastAppliedViewportCrossSize = viewportCrossSize val frame = createContentFrame() scrollView?.contentView?.setFrameToRenderView(frame) } @@ -208,6 +221,7 @@ class KuiklyScrollInfo { cachedTotalItems = 0 pullToRefreshTopInsetPx = 0 lastAppliedContentSize = -1 + lastAppliedViewportCrossSize = -1f pendingBottomExpand = false } From 6ab31becc972c2de3d145511aa2bbd72ba95b4af Mon Sep 17 00:00:00 2001 From: zenipchen Date: Mon, 29 Jun 2026 20:13:58 +0800 Subject: [PATCH 027/187] fix(ohos): sync nested scroll boundary handling to compose scroll sync Clamp ArkUI scroll-frame-begin offset at nested list edges and skip compose-side expand/offset correction when nestedScroll is configured. Co-authored-by: Cursor --- .../compose/scroller/ContentSizeExtensions.kt | 10 +++- .../scroller/ScrollableStateExtensions.kt | 10 ++++ .../compose/ui/layout/SubcomposeLayout.kt | 15 ++++- .../components/scroller/KRScrollerView.cpp | 57 +++++++++++++++++++ .../components/scroller/KRScrollerView.h | 5 ++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index c8d4edc19..72b211f41 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -112,7 +112,9 @@ internal fun ScrollableState.finalizeNativeScrollSync(offset: Int) { if (kuiklyInfo.pendingBottomExpand) { kuiklyInfo.pendingBottomExpand = false } - tryExpandStartSize(offset, isScrolling = false) + if (!isNestedScrollConfigured()) { + tryExpandStartSize(offset, isScrolling = false) + } } internal fun PaddingValues.totalPadding(orientation: Orientation): Dp { @@ -275,7 +277,7 @@ internal fun ScrollableState.calculateBackExpandSize(offset: Int): Int? { * 尝试扩展起始大小 */ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolean) { - if (kuiklyInfo.scrollView == null) return + if (kuiklyInfo.scrollView == null || isNestedScrollConfigured()) return val atTopSync = isComposeAtTopForScrollSync() val needsTopExpand = offset <= 0 && !atTopSync && kuiklyInfo.offsetDirty @@ -326,6 +328,10 @@ internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = f val epsilon = 0.5 * getDensity() // 使用 0.5dp 作为误差值 val reachBtm = contentOffset + viewportSize - currentContentSize >= -epsilon + if (isNestedScrollConfigured()) { + return@launch + } + if (contentOffset <= 0 && !isComposeAtTopForScrollSync() && (forceExpand || scrollView?.isDragging != true)) { // 整体把offset 加一下 var delta = calculateBackExpandSize(contentOffset) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt index 217b93162..c3ebd3d82 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt @@ -25,6 +25,7 @@ import com.tencent.kuikly.compose.foundation.pager.PagerState import com.tencent.kuikly.compose.views.applyOffsetDelta import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo import com.tencent.kuikly.compose.gestures.KuiklyScrollableState +import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollParams /** @@ -143,6 +144,15 @@ internal suspend fun ScrollableState.animateScrollToTop() { } } +/** + * Whether native nestedScroll is configured on the bound ScrollerView. + */ +internal fun ScrollableState.isNestedScrollConfigured(): Boolean { + val prop = kuiklyInfo.scrollView?.getViewAttr()?.getProp(ScrollerAttr.NESTED_SCROLL) ?: return false + val value = prop.toString() + return value.isNotEmpty() && value != "null" && value != "{}" +} + /** * Apply scroll view offset delta */ diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 4f55aa432..9d0aaa38b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -75,7 +75,7 @@ import com.tencent.kuikly.compose.layout.hideOffsetScreenView import com.tencent.kuikly.compose.layout.restoreScrollerViewOnReuse import com.tencent.kuikly.compose.layout.transferScrollToTopCallback import com.tencent.kuikly.compose.scroller.handleScrollToTopCallback -import com.tencent.kuikly.compose.scroller.isAtTop +import com.tencent.kuikly.compose.scroller.isNestedScrollConfigured import com.tencent.kuikly.compose.scroller.lastItemVisible import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.scroller.kuiklyOnScroll @@ -94,6 +94,7 @@ import com.tencent.kuikly.compose.scroller.animateScrollToTop import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSize import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSizeIfNeeded import com.tencent.kuikly.compose.scroller.finalizeNativeScrollSync +import com.tencent.kuikly.compose.scroller.isAtTop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @@ -346,9 +347,15 @@ fun SubcomposeLayout( if (offset < 0 && scrollableState.isAtTop()) { KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll + } else if (scrollableState.isNestedScrollConfigured() && scrollDelta > 0 && !scrollableState.canScrollForward) { + // 嵌套滚动到底:交给外层 ArkUI nestedScroll 消费,Compose 侧不再驱动 remeasure + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } + return@scroll } else if (toButtomDelta != null && scrollDelta > toButtomDelta) { if (toButtomDelta.toInt() <= 0) { - kuiklyInfo.pendingBottomExpand = true + if (!scrollableState.isNestedScrollConfigured()) { + kuiklyInfo.pendingBottomExpand = true + } KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll } @@ -361,7 +368,9 @@ fun SubcomposeLayout( KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.kuiklyOnScroll++ } scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) scrollableState.calculateAndUpdateContentSizeIfNeeded() - scrollableState.tryExpandStartSize(offset, true) + if (!scrollableState.isNestedScrollConfigured()) { + scrollableState.tryExpandStartSize(offset, true) + } } // Listen to native "scroll to top" event and scroll to index 0 diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp index 5199ac473..ab52cef6d 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp @@ -15,8 +15,10 @@ #include "libohos_render/expand/components/scroller/KRScrollerView.h" +#include #include #include +#include #include "libohos_render/utils/KRRenderLoger.h" #include "libohos_render/expand/components/view/KRView.h" #include "libohos_render/foundation/type/KRRenderValue.h" @@ -208,6 +210,7 @@ bool KRScrollerView::ResetProp(const std::string &prop_key) { if (!didHanded) { if (prop_key == kPropNameNestedScroll) { didHanded = true; + has_nested_scroll_ = false; kuikly::util::ResetArkUINestedScroll(GetNode()); } else if (prop_key == kPropNameFlingEnable) { didHanded = true; @@ -345,6 +348,9 @@ bool KRScrollerView::SetNestedScroll(const KRAnyValue &value) { ArkUI_ScrollNestedMode forward = ParseOption(forwardStr); ArkUI_ScrollNestedMode backward = ParseOption(backwardStr); + has_nested_scroll_ = true; + nested_scroll_forward_ = forward; + nested_scroll_backward_ = backward; kuikly::util::SetArkUINestedScroll(GetNode(), forward, backward); return true; } @@ -564,6 +570,56 @@ void KRScrollerView::OnScrollFrameBegin(ArkUI_NodeEvent *event) { last_scroll_time_ = current_time; last_scroll_x_ = point.x; last_scroll_y_ = point.y; + + if (!has_nested_scroll_ || !content_view_) { + return; + } + auto component_event = OH_ArkUI_NodeEvent_GetNodeComponentEvent(event); + if (!component_event) { + return; + } + const float scroll_amount = component_event->data[0].f32; + const auto frame = GetFrame(); + const auto content_frame = content_view_->GetFrame(); + const float viewport = direction_row_ ? frame.width : frame.height; + const float content_size = direction_row_ ? content_frame.width : content_frame.height; + const float max_offset = std::max(0.f, content_size - viewport); + const float current_offset = direction_row_ ? point.x : point.y; + + float offset_remain = scroll_amount; + if (ShouldHandOffNestedScrollAtBoundary(scroll_amount, current_offset, max_offset)) { + offset_remain = 0.f; + } else if (scroll_amount > 0.f) { + offset_remain = std::min(scroll_amount, std::max(0.f, max_offset - current_offset)); + } else if (scroll_amount < 0.f) { + offset_remain = std::max(scroll_amount, -current_offset); + } + + if (fabsf(offset_remain - scroll_amount) > 0.01f) { + ArkUI_NumberValue ret[] = {{.f32 = offset_remain}}; + OH_ArkUI_NodeEvent_SetReturnNumberValue(event, ret, 1); + } +} + +bool KRScrollerView::ShouldHandOffNestedScrollAtBoundary(float scroll_amount, float current_offset, + float max_offset) const { + if (!has_nested_scroll_) { + return false; + } + constexpr float kBoundaryEpsilon = 0.5f; + const bool at_top = current_offset <= kBoundaryEpsilon; + const bool at_bottom = current_offset >= max_offset - kBoundaryEpsilon; + const auto handoff_mode = [&](bool scrolling_forward) { + const auto mode = scrolling_forward ? nested_scroll_forward_ : nested_scroll_backward_; + return mode == ARKUI_SCROLL_NESTED_MODE_SELF_FIRST || mode == ARKUI_SCROLL_NESTED_MODE_PARENT_FIRST; + }; + if (at_top && scroll_amount < 0.f && handoff_mode(false)) { + return true; + } + if (at_bottom && scroll_amount > 0.f && handoff_mode(true)) { + return true; + } + return false; } void KRScrollerView::OnScrollStop(ArkUI_NodeEvent *event) { @@ -809,6 +865,7 @@ void KRScrollerView::PrepareForComposeReuse() { last_move_time_ = 0; velocity_x_ = 0; velocity_y_ = 0; + has_nested_scroll_ = false; } void KRScrollerView::AbortContentOffsetAnimate() { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h index 1ea0d52c3..4379a34dc 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h @@ -181,6 +181,11 @@ class KRScrollerView : public IKRRenderViewExport { uint32_t trace_fire_skipped_ = 0; uint32_t trace_fire_to_bridge_ = 0; void DumpScrollTrace(const char *phase); + bool ShouldHandOffNestedScrollAtBoundary(float scroll_amount, float current_offset, float max_offset) const; + + bool has_nested_scroll_ = false; + ArkUI_ScrollNestedMode nested_scroll_forward_ = ARKUI_SCROLL_NESTED_MODE_SELF_FIRST; + ArkUI_ScrollNestedMode nested_scroll_backward_ = ARKUI_SCROLL_NESTED_MODE_SELF_FIRST; }; #endif // CORE_RENDER_OHOS_KRSCROLLERVIEW_H From 31bd372fe2a24347c0cd87a81102a4c88e95e1e2 Mon Sep 17 00:00:00 2001 From: zenipchen Date: Tue, 30 Jun 2026 12:07:31 +0800 Subject: [PATCH 028/187] fix(ohos): dedup calculateContentSize during LazyColumn mid-scroll Only recalculate native content size when contentView main-axis height changes during fling, avoiding ~650 redundant calc calls per gesture while keeping near-bottom and scrollEnd sync unchanged. Co-authored-by: Cursor --- .../compose/gestures/KuiklyScrollInfo.kt | 13 +++++++++++ .../compose/gestures/KuiklyScrollTrace.kt | 4 +++- .../compose/scroller/ContentSizeExtensions.kt | 22 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 7ddac0a2b..e5c36ef41 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -82,6 +82,9 @@ class KuiklyScrollInfo { */ var realContentSize: Int? = null + /** 上次 calc 时读到的 native contentView 主轴尺寸(px),用于滚动中节流 */ + internal var lastSyncedNativeContentMainAxisPx: Int = -1 + /** * Whether the offset has deviation */ @@ -222,9 +225,19 @@ class KuiklyScrollInfo { pullToRefreshTopInsetPx = 0 lastAppliedContentSize = -1 lastAppliedViewportCrossSize = -1f + lastSyncedNativeContentMainAxisPx = -1 pendingBottomExpand = false } + internal fun nativeContentMainAxisDp(): Float { + val scrollView = scrollView ?: return -1f + return if (orientation == Orientation.Vertical) { + scrollView.contentView?.renderView?.currentFrame?.height ?: -1f + } else { + scrollView.contentView?.renderView?.currentFrame?.width ?: -1f + } + } + /** * Create content Frame */ diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt index ac9a29106..f80b8f53a 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt @@ -35,6 +35,7 @@ internal object KuiklyScrollTrace { var contentSizeDeduped = 0 var lazyRemeasure = 0 var lazyScrollWithoutRemeasure = 0 + var calcSizeSkipped = 0 inline fun ifEnabled(block: () -> Unit) { if (ENABLED) block() @@ -51,6 +52,7 @@ internal object KuiklyScrollTrace { contentSizeDeduped = 0 lazyRemeasure = 0 lazyScrollWithoutRemeasure = 0 + calcSizeSkipped = 0 } fun dumpSummary(phase: String) { @@ -59,7 +61,7 @@ internal object KuiklyScrollTrace { "[$TAG] $phase | " + "composeIn=$composeScrollReceived " + "filtered=$composeDeltaFiltered earlyRet=$composeEarlyReturn " + - "calcSize=$calculateContentSize kuiklyScroll=$kuiklyOnScroll expand=$tryExpandStartSize " + + "calcSize=$calculateContentSize skipped=$calcSizeSkipped kuiklyScroll=$kuiklyOnScroll expand=$tryExpandStartSize " + "setFrame=$contentSizeToRender dedup=$contentSizeDeduped " + "remeasure=$lazyRemeasure noRemeasure=$lazyScrollWithoutRemeasure " + "(native fireToBridge see hilog KuiklyScrollTrace)" diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 72b211f41..4bb6f11a2 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -99,8 +99,28 @@ internal fun ScrollableState.calculateAndUpdateContentSize() { * [force] 用于 scrollEnd 等必须同步的时机。 */ internal fun ScrollableState.calculateAndUpdateContentSizeIfNeeded(force: Boolean = false) { - if (force || kuiklyInfo.nearScrollBottom() || kuiklyInfo.realContentSize == null) { + if (force) { calculateAndUpdateContentSize() + return + } + if (kuiklyInfo.nearScrollBottom()) { + calculateAndUpdateContentSize() + return + } + if (kuiklyInfo.realContentSize != null) { + return + } + val nativeDp = kuiklyInfo.nativeContentMainAxisDp() + if (nativeDp < 0f) { + calculateAndUpdateContentSize() + return + } + val nativePx = (nativeDp * kuiklyInfo.getDensity()).toInt() + if (nativePx != kuiklyInfo.lastSyncedNativeContentMainAxisPx) { + kuiklyInfo.lastSyncedNativeContentMainAxisPx = nativePx + calculateAndUpdateContentSize() + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.calcSizeSkipped++ } } } From 6bbb4caa338950ae5e30490b73265eeec2e6116a Mon Sep 17 00:00:00 2001 From: zenipchen Date: Tue, 30 Jun 2026 17:10:33 +0800 Subject: [PATCH 029/187] perf(ohos): add LazyColumn scroll audit trace and state write gates Extend KuiklyScrollTrace with remeasure, frame timing, and offset/drag skip metrics for OHOS scroll stress validation; gate redundant contentOffset and isDragging writes; skip resetViewVisible when unchanged. Co-authored-by: Cursor --- .../compose/foundation/lazy/LazyListState.kt | 3 + .../compose/gestures/KuiklyScrollTrace.kt | 61 +++++++++++++++++-- .../compose/layout/SubcomposeLayoutEx.kt | 11 ++-- .../kuikly/compose/ui/layout/Placeable.kt | 2 + .../compose/ui/layout/SubcomposeLayout.kt | 43 +++++++++++-- .../tencent/kuikly/compose/ui/node/KNode.kt | 9 +++ 6 files changed, 113 insertions(+), 16 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt index df2777532..20b163597 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt @@ -57,6 +57,7 @@ import com.tencent.kuikly.compose.ui.unit.dp import com.tencent.kuikly.compose.ui.util.fastFirstOrNull import com.tencent.kuikly.compose.ui.util.fastRoundToInt import com.tencent.kuikly.compose.ui.util.fastSumBy +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.scroller.tryExpandStartSizeNoScroll import com.tencent.kuikly.compose.profiler.RecompositionProfiler @@ -415,6 +416,7 @@ class LazyListState ) } if (scrolledWithoutRemeasure) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.lazyScrollWithoutRemeasure++ } applyMeasureResult( result = layoutInfo, isLookingAhead = hasLookaheadPassOccurred, @@ -423,6 +425,7 @@ class LazyListState // we don't need to remeasure, so we only trigger re-placement: placementScopeInvalidator.invalidateScope() } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.lazyRemeasure++ } remeasurement?.forceRemeasure() } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt index f80b8f53a..5ea51c74b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt @@ -18,9 +18,11 @@ package com.tencent.kuikly.compose.gestures /** * 滚动链路诊断:统计一次手势内各层调用次数,在 scrollEnd 时汇总打印。 * 过滤:hilog | grep KuiklyScrollTrace + * + * 验收时临时设 [ENABLED]=true;合入前保持 false。 */ internal object KuiklyScrollTrace { - /** 调试时设为 true;发布前保持 false */ + /** 调试/验收时设为 true;发布前保持 false */ const val ENABLED = false private const val TAG = "KuiklyScrollTrace" @@ -36,6 +38,28 @@ internal object KuiklyScrollTrace { var lazyRemeasure = 0 var lazyScrollWithoutRemeasure = 0 var calcSizeSkipped = 0 + var kuiklyScrollNs = 0L + var calcSizeNs = 0L + + // scroll audit(极致性能验收指标) + var updateKuiklyViewFrameCalls = 0 + /** compute 之前的脏检查跳过(避免 viewPositionOf 坐标链 walk) */ + var framePreSkip = 0 + /** compute 之后 frame 未变跳过 */ + var frameSyncSkipped = 0 + /** placeSelf + delegate 同轮 placement 去重 */ + var framePlacementDedup = 0 + var frameComputeNs = 0L + var resetVisibleSkipped = 0 + var contentOffsetWrites = 0 + var contentOffsetSkipped = 0 + var isDraggingWrites = 0 + var isDraggingSkipped = 0 + var contentSizeStateWrites = 0 + var contentSizeStateSkipped = 0 + var coordAccessMark = 0 + var coordAccessRelayout = 0 + var placementCoordAccess = 0 inline fun ifEnabled(block: () -> Unit) { if (ENABLED) block() @@ -53,18 +77,45 @@ internal object KuiklyScrollTrace { lazyRemeasure = 0 lazyScrollWithoutRemeasure = 0 calcSizeSkipped = 0 + kuiklyScrollNs = 0L + calcSizeNs = 0L + updateKuiklyViewFrameCalls = 0 + framePreSkip = 0 + frameSyncSkipped = 0 + framePlacementDedup = 0 + frameComputeNs = 0L + resetVisibleSkipped = 0 + contentOffsetWrites = 0 + contentOffsetSkipped = 0 + isDraggingWrites = 0 + isDraggingSkipped = 0 + contentSizeStateWrites = 0 + contentSizeStateSkipped = 0 + coordAccessMark = 0 + coordAccessRelayout = 0 + placementCoordAccess = 0 } fun dumpSummary(phase: String) { if (!ENABLED) return + val scrollMs = (kuiklyScrollNs / 1_000_000.0 * 10).toLong() / 10.0 + val calcMs = (calcSizeNs / 1_000_000.0 * 10).toLong() / 10.0 + val frameMs = (frameComputeNs / 1_000_000.0 * 10).toLong() / 10.0 + val remeasureRate = if (kuiklyOnScroll > 0) { + (lazyRemeasure * 1000 / kuiklyOnScroll) / 10.0 + } else 0.0 println( "[$TAG] $phase | " + - "composeIn=$composeScrollReceived " + - "filtered=$composeDeltaFiltered earlyRet=$composeEarlyReturn " + + "composeIn=$composeScrollReceived filtered=$composeDeltaFiltered earlyRet=$composeEarlyReturn " + "calcSize=$calculateContentSize skipped=$calcSizeSkipped kuiklyScroll=$kuiklyOnScroll expand=$tryExpandStartSize " + "setFrame=$contentSizeToRender dedup=$contentSizeDeduped " + - "remeasure=$lazyRemeasure noRemeasure=$lazyScrollWithoutRemeasure " + - "(native fireToBridge see hilog KuiklyScrollTrace)" + "remeasure=$lazyRemeasure noRemeasure=$lazyScrollWithoutRemeasure remeasureRate=${remeasureRate}% " + + "scrollMs=$scrollMs calcMs=$calcMs frameMs=$frameMs | " + + "audit: frameCalls=$updateKuiklyViewFrameCalls preSkip=$framePreSkip postSkip=$frameSyncSkipped placeDedup=$framePlacementDedup resetSkip=$resetVisibleSkipped " + + "offW=$contentOffsetWrites offSkip=$contentOffsetSkipped " + + "dragW=$isDraggingWrites dragSkip=$isDraggingSkipped " + + "sizeW=$contentSizeStateWrites sizeSkip=$contentSizeStateSkipped " + + "coordMark=$coordAccessMark coordRelayout=$coordAccessRelayout placeCoord=$placementCoordAccess" ) } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt index a8101d428..2de411374 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt @@ -22,6 +22,7 @@ import com.tencent.kuikly.compose.foundation.lazy.grid.LazyGridMeasureResult import com.tencent.kuikly.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridMeasureResult import com.tencent.kuikly.compose.foundation.pager.PagerMeasureResult import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.ui.layout.LayoutNodeSubcompositionsState import com.tencent.kuikly.compose.ui.layout.MeasureResult @@ -83,12 +84,12 @@ internal fun KNode<*>.hideOffsetScreenView() { internal fun KNode<*>.resetViewVisible() { when { isVirtual -> forEachChild { (it as? KNode<*>)?.resetViewVisible() } + viewVisible == null -> { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.resetVisibleSkipped++ } + } else -> { - // 恢复到原始的Visible属性 - viewVisible?.let { - view.getViewAttr().visibility(it) - viewVisible = null - } + view.getViewAttr().visibility(viewVisible!!) + viewVisible = null } } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt index 9e041d5f9..5e3963683 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt @@ -16,6 +16,7 @@ package com.tencent.kuikly.compose.ui.layout +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.ui.graphics.GraphicsLayerScope import com.tencent.kuikly.compose.ui.node.LookaheadCapablePlaceable import com.tencent.kuikly.compose.ui.node.MotionReferencePlacementDelegate @@ -573,6 +574,7 @@ private class LookaheadCapablePlacementScope( // if coordinates are not null we will only set this flag when the inner // coordinate values are read. see NodeCoordinator.onCoordinatesUsed() if (coords == null) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.placementCoordAccess++ } within.layoutNode.layoutDelegate.onCoordinatesUsed() } return coords diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 9d0aaa38b..d77191b27 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -87,6 +87,7 @@ import com.tencent.kuikly.compose.ui.node.KNode.Companion.obtainRenderProps import com.tencent.kuikly.compose.ui.scaleWithDensity import com.tencent.kuikly.core.base.DeclarativeBaseView import com.tencent.kuikly.core.base.event.layoutFrameDidChange +import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollerEvent import com.tencent.kuikly.core.views.ScrollerView @@ -288,7 +289,12 @@ fun SubcomposeLayout( scrollEnd { val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() - kuiklyInfo.contentOffset = offset + if (kuiklyInfo.contentOffset != offset) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } + kuiklyInfo.contentOffset = offset + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } + } (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) // 仅触摸滑动结束会回调,api调用和bounce回弹都不会触发 @@ -300,8 +306,19 @@ fun SubcomposeLayout( dragEnd { val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() - kuiklyInfo.contentOffset = offset - kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false + if (kuiklyInfo.contentOffset != offset) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } + kuiklyInfo.contentOffset = offset + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } + } + val dragging = kuiklyInfo.scrollView?.isDragging ?: false + if (kuiklyInfo.isDragging != dragging) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingWrites++ } + kuiklyInfo.isDragging = dragging + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingSkipped++ } + } } scroll { KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeScrollReceived++ } @@ -309,10 +326,20 @@ fun SubcomposeLayout( val nativeOffset = if (isVertical) scaleParams.offsetY else scaleParams.offsetX val offset = nativeOffset.fastRoundToInt() - val prevOffset = kuiklyInfo.contentOffset - kuiklyInfo.contentOffset = offset + if (kuiklyInfo.contentOffset != offset) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } + kuiklyInfo.contentOffset = offset + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } + } (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) - kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false + val dragging = kuiklyInfo.scrollView?.isDragging ?: false + if (kuiklyInfo.isDragging != dragging) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingWrites++ } + kuiklyInfo.isDragging = dragging + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingSkipped++ } + } if (kuiklyInfo.ignoreScrollOffset != null) { val ignoreOffset = kuiklyInfo.ignoreScrollOffset!! @@ -366,11 +393,15 @@ fun SubcomposeLayout( // 仅在实际驱动 LazyList 滚动后同步 contentSize / offset 校正 KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.kuiklyOnScroll++ } + val scrollT0 = if (KuiklyScrollTrace.ENABLED) DateTime.nanoTime() else 0L scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) scrollableState.calculateAndUpdateContentSizeIfNeeded() if (!scrollableState.isNestedScrollConfigured()) { scrollableState.tryExpandStartSize(offset, true) } + if (KuiklyScrollTrace.ENABLED) { + KuiklyScrollTrace.kuiklyScrollNs += DateTime.nanoTime() - scrollT0 + } } // Listen to native "scroll to top" event and scroll to index 0 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt index a0b46c00b..827ce81cd 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt @@ -30,6 +30,7 @@ import com.tencent.kuikly.compose.ui.layout.LayoutCoordinates import com.tencent.kuikly.compose.ui.platform.LocalDensity import com.tencent.kuikly.compose.ui.unit.IntSize import com.tencent.kuikly.compose.views.VirtualNodeView +import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.layout.resetViewVisible import com.tencent.kuikly.compose.ui.KuiklyPath import com.tencent.kuikly.compose.ui.layout.LookaheadLayoutCoordinates @@ -45,6 +46,7 @@ import com.tencent.kuikly.core.base.Translate import com.tencent.kuikly.core.base.ViewContainer import com.tencent.kuikly.core.base.domChildren import com.tencent.kuikly.core.base.event.notifyLayoutFrameDidChange +import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.layout.Frame import com.tencent.kuikly.core.views.DivView import com.tencent.kuikly.core.views.HoverView @@ -251,6 +253,8 @@ internal class KNode>( } override fun updateKuiklyViewFrame(coordinator: LayoutCoordinates) { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.updateKuiklyViewFrameCalls++ } + val frameT0 = if (KuiklyScrollTrace.ENABLED) DateTime.nanoTime() else 0L val curCoordinator = kuiklyCoordinates ?: innerCoordinator resetViewVisible() @@ -299,6 +303,9 @@ internal class KNode>( } view.updateFrame(newFrame) + if (KuiklyScrollTrace.ENABLED) { + KuiklyScrollTrace.frameComputeNs += DateTime.nanoTime() - frameT0 + } } /** @@ -397,6 +404,8 @@ internal class KNode>( updateScrollViewOffset(curFrame, densityFrame) setFrameToRenderView(densityFrame) getViewEvent().notifyLayoutFrameDidChange(newFrame) + } else { + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.frameSyncSkipped++ } } } From 88eb73d8680c170e1a57f8394489182e6274f557 Mon Sep 17 00:00:00 2001 From: artin Date: Tue, 30 Jun 2026 22:47:56 +0800 Subject: [PATCH 030/187] fix(ios): disable implicit border layer animations --- core-render-ios/Extension/Category/UIView+CSS.m | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core-render-ios/Extension/Category/UIView+CSS.m b/core-render-ios/Extension/Category/UIView+CSS.m index fc98ae3c1..d62220bf5 100644 --- a/core-render-ios/Extension/Category/UIView+CSS.m +++ b/core-render-ios/Extension/Category/UIView+CSS.m @@ -1568,16 +1568,15 @@ - (void)setNeedsRedraw { */ - (void)layoutSublayers { [super layoutSublayers]; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; // 0. macOS: 确保边框在最顶层(NSScrollView/NSTextView 内部 sublayer 可能覆盖边框) #if TARGET_OS_OSX if (self.superlayer && [[self.superlayer sublayers] lastObject] != self) { - [CATransaction begin]; - [CATransaction setDisableActions:YES]; CALayer *superlayer = self.superlayer; [self removeFromSuperlayer]; [superlayer addSublayer:self]; - [CATransaction commit]; } #endif @@ -1589,6 +1588,7 @@ - (void)layoutSublayers { // 2. 尺寸未变化时跳过重绘(性能优化)或者重绘标志位为false // 仅在 clipPath 变化时为 YES) if (CGSizeEqualToSize(self.bounds.size, _lastSize) && !_needsRedraw) { + [CATransaction commit]; return ; } _lastSize = self.bounds.size; @@ -1654,6 +1654,7 @@ - (void)layoutSublayers { #else self.path = path.CGPath; #endif + [CATransaction commit]; } @end From f20bafbe72c4e7e8eb5c9e195522c0efd5cd9268 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 2 Jul 2026 00:35:32 +0800 Subject: [PATCH 031/187] fix(compose): schedule pull refresh bridge on kuikly thread --- .../compose/gestures/KuiklyScrollInfo.kt | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 7988cd0c6..a987cc626 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -18,10 +18,12 @@ package com.tencent.kuikly.compose.gestures import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.tencent.kuikly.compose.coroutines.internal.KuiklyContextScheduler import com.tencent.kuikly.compose.foundation.gestures.Orientation import com.tencent.kuikly.compose.ui.node.StickyHeaderCacheManager import com.tencent.kuikly.compose.ui.unit.IntOffset import com.tencent.kuikly.core.layout.Frame +import com.tencent.kuikly.core.manager.BridgeManager import com.tencent.kuikly.core.pager.PageData import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollerEvent @@ -51,7 +53,7 @@ class KuiklyScrollInfo { set(value) { field = value if (hasPullToRefresh && value != null) { - value.setHasPullToRefresh(true) + updatePullToRefreshOnScrollView(value, true) } } @@ -131,12 +133,32 @@ class KuiklyScrollInfo { var hasPullToRefresh: Boolean = false set(value) { field = value - if (value) { - scrollView?.setHasPullToRefresh(true) - } else { - scrollView?.setHasPullToRefresh(false) + scrollView?.let { updatePullToRefreshOnScrollView(it, value) } + } + + private fun updatePullToRefreshOnScrollView( + targetScrollView: ScrollerView, + enabled: Boolean + ) { + val pagerId = targetScrollView.pagerId.ifEmpty { BridgeManager.currentPageId } + fun applyIfCurrent() { + if (scrollView === targetScrollView && hasPullToRefresh == enabled) { + targetScrollView.setHasPullToRefresh(enabled) + } + } + if (KuiklyContextScheduler.isOnKuiklyThread(pagerId)) { + applyIfCurrent() + return + } + if (pagerId.isEmpty()) { + return + } + KuiklyContextScheduler.runOnKuiklyThread(pagerId) { cancel -> + if (!cancel) { + applyIfCurrent() } } + } /** * Extra top inset on the pull-to-refresh lazy item in pixels, @@ -249,4 +271,4 @@ class KuiklyScrollInfo { val threshold = SCROLL_BOTTOM_THRESHOLD * getDensity() return contentOffset + viewportSize + threshold > currentContentSize } -} \ No newline at end of file +} From a63655b1881fb53854f0cb5a0449dccfd9cc1977 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 2 Jul 2026 02:07:47 +0800 Subject: [PATCH 032/187] fix(android): render rich text span backgrounds --- .../compose/foundation/text/KuiklyTextExtension.kt | 3 +++ .../android/expand/component/text/KRRichTextBuilder.kt | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 4f9cf6146..2dec8be5b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -452,6 +452,9 @@ internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { applyShadow(spanStyle.shadow) applyStyleColor(spanStyle) + if (spanStyle.background.isSpecified) { + setProp(Attr.StyleConst.BACKGROUND_COLOR, spanStyle.background.toKuiklyColor().toString()) + } if (spanStyle.brush is SolidColor) { color((spanStyle.brush as SolidColor).value.toKuiklyColor()) } else if (spanStyle.brush is LinearGradient) { diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 2e3b47af9..3f1c5c73c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -28,6 +28,7 @@ import android.text.Spannable import android.text.SpannableStringBuilder import android.text.TextPaint import android.text.style.AbsoluteSizeSpan +import android.text.style.BackgroundColorSpan import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan import android.text.style.LeadingMarginSpan @@ -202,6 +203,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { // 修饰相关 textSpans.add(ForegroundColorSpan(spanProps.color)) + if (spanProps.backgroundColor != Color.TRANSPARENT) { + textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) + } if (spanProps.textDecoration.isNotEmpty()) { if (spanProps.textDecoration == KRTextProps.TEXT_DECORATION_LINE_THROUGH) { textSpans.add(StrikethroughSpan()) @@ -263,6 +267,7 @@ class TextSpanProps( val textDecoration: String val lineHeight: Float val backgroundImage: String + val backgroundColor: Int val slockInlineCode: Boolean var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -310,6 +315,11 @@ class TextSpanProps( defaultProps.lineHeight } backgroundImage = spanValue.optString(KRTextProps.PROP_KEY_BACKGROUND_IMAGE, defaultProps.backgroundImage) + backgroundColor = + spanValue.optString(KRCssConst.BACKGROUND_COLOR) + .takeIf { it.isNotEmpty() } + ?.toColor() + ?: Color.TRANSPARENT slockInlineCode = spanValue.optInt(TextConst.SLOCK_INLINE_CODE, 0) == 1 || spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE, false) val textShadowStr = spanValue.optString(KRTextProps.PROP_KEY_TEXT_SHADOW, "") From 5405fa98112a03b73dad8bed97df9033df234df4 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 2 Jul 2026 02:44:05 +0800 Subject: [PATCH 033/187] fix(render): support rich text span backgrounds on apple and ohos --- .../Extension/AdvancedComps/KRRichTextView.h | 1 + .../Extension/AdvancedComps/KRRichTextView.m | 6 ++++++ .../components/richtext/KRRichTextShadow.cpp | 14 +++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h index a761ad645..3cf3757f8 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h @@ -47,6 +47,7 @@ extern NSString *const KuiklyIndexAttributeName; @property (nonatomic, assign) NSUInteger spanIndex; @property (nonatomic, strong) UIFont *font; @property (nonatomic, strong) UIColor *color; +@property (nonatomic, strong) UIColor *backgroundColor; @property (nonatomic, assign) BOOL hasGradient; @property (nonatomic, copy) NSString *cssGradient; @property (nonatomic, assign) CGFloat letterSpacing; diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index 9f0d631f3..500f62a09 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -244,6 +244,7 @@ - (NSMutableAttributedString *)p_buildAttributedString { // 批量解析与字体相关的属性 UIFont *font = [KRConvertUtil UIFont:propStyle]; UIColor * color = [UIView css_color:propStyle[@"color"]] ?: [UIColor blackColor]; + UIColor *backgroundColor = [UIView css_color:span[@"backgroundColor"]]; NSString *cssGricent = propStyle[@"backgroundImage"]; BOOL hasGradient = NO; if (cssGricent && [cssGricent hasPrefix:@"linear-gradient("]) { @@ -296,6 +297,7 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.spanIndex = spanIndex; spanAttrs.font = font; spanAttrs.color = color; + spanAttrs.backgroundColor = backgroundColor; spanAttrs.hasGradient = hasGradient; spanAttrs.cssGradient = cssGricent; spanAttrs.letterSpacing = letterSpacing; @@ -371,6 +373,10 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut [attributedString addAttribute:NSKernAttributeName value:@(attrs.letterSpacing) range:range]; } + if (attrs.backgroundColor) { + [attributedString addAttribute:NSBackgroundColorAttributeName value:attrs.backgroundColor range:range]; + } + if (attrs.textDecoration == KRTextDecorationLineTypeUnderline) { [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:range]; } diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp index e31254a80..855212fb3 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp @@ -446,6 +446,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w auto fontWeight = kuikly::util::ConvertFontWeight(GetKRValue("fontWeight", spanMap, props_)->toInt(), fontWeightScale); // 解析基于Span的多个渐变色属性 auto colorStr = GetKRValue("color", spanMap, props_)->toString(); + auto backgroundColorStr = GetKRValue("backgroundColor", spanMap, spanMap)->toString(); auto backgroundImage = GetKRValue("backgroundImage", spanMap, props_)->toString(); OH_Drawing_ShaderEffect *colorShaderEffect = nullptr; auto linearGradient = std::make_shared(); @@ -453,6 +454,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w auto fontFamily = GetKRValue("fontFamily", spanMap, props_)->toString(); auto color = colorStr.length() ? kuikly::util::ConvertToHexColor(colorStr) : 0xff000000; // 默认黑色 + auto backgroundColor = backgroundColorStr.length() ? kuikly::util::ConvertToHexColor(backgroundColorStr) : 0x00000000; auto lineHeight = GetKRValue("lineHeight", spanMap, props_)->toFloat() / (fontSize / dpi); // 字体比例 auto lineSpacing = GetKRValue("lineSpacing", spanMap, props_)->toFloat() / (fontSize / dpi); // 行间距比例 auto textAlign = kuikly::util::ConvertToTextAlign(GetKRValue("textAlign", spanMap, props_)->toString()); @@ -469,8 +471,14 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_TextStyle *txtStyle = OH_Drawing_CreateTextStyle(); OH_Drawing_Pen *textForegroundPen = nullptr; OH_Drawing_Brush *textForegroundBrush = OH_Drawing_BrushCreate(); + OH_Drawing_Brush *textBackgroundBrush = nullptr; // 设置文字大小、字重等属性设置到文本样式对象中 OH_Drawing_SetTextStyleColor(txtStyle, color); + if (backgroundColorStr.length() && backgroundColor != 0x00000000) { + textBackgroundBrush = OH_Drawing_BrushCreate(); + OH_Drawing_BrushSetColor(textBackgroundBrush, backgroundColor); + OH_Drawing_SetTextStyleBackgroundBrush(txtStyle, textBackgroundBrush); + } if (textShadowStr.length()) { auto textShadow = OH_Drawing_CreateTextShadow(); kuikly::util::SetTextShadow(textShadow, textShadowStr); @@ -653,6 +661,10 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_BrushDestroy(textForegroundBrush); textForegroundBrush = nullptr; } + if (textBackgroundBrush) { + OH_Drawing_BrushDestroy(textBackgroundBrush); + textBackgroundBrush = nullptr; + } spanIndex++; } // 根据handler对象生成文本排版布局typography @@ -839,4 +851,4 @@ void KRRichTextShadow::DestroyCachedTextLines(){ OH_Drawing_DestroyTextLines(text_lines_); text_lines_ = nullptr; } -} \ No newline at end of file +} From 06db5e87d166fd7dfa7a9812763b6773e56aa4f6 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 2 Jul 2026 03:04:44 +0800 Subject: [PATCH 034/187] fix(compose): rename slock inline code annotation namespace Signed-off-by: artin --- .../kuikly/compose/foundation/text/KuiklyTextExtension.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 2dec8be5b..7626f270f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -53,7 +53,7 @@ import com.tencent.kuikly.core.views.TextAttr import com.tencent.kuikly.core.views.TextConst import com.tencent.kuikly.core.views.TextSpan -private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "ai.slock.markdown.inlineCode" +private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "raft.build.markdown.inlineCode" // Returns platform-specific default font size private fun TextAttr.defaultFontSize(): Float { From cc61d6f12bfaaf163a53b5fb82fdce95da0ef478 Mon Sep 17 00:00:00 2001 From: artin Date: Thu, 2 Jul 2026 03:17:20 +0800 Subject: [PATCH 035/187] fix(android): draw inline code border above text background Signed-off-by: artin --- .../expand/component/text/KRRichTextViewDrawer.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index f7fd0dcda..bad0bcf01 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -90,11 +90,12 @@ class KRRichTextViewDrawer(val textLayout: Layout) { * 将文本内容绘制到 [canvas],对接到 [Layout.draw]。 */ fun draw(canvas: Canvas) { - drawSlockInlineCodeBackgrounds(canvas) + drawSlockInlineCodeChrome(canvas, drawFill = true, drawBorder = false) textLayout.draw(canvas) + drawSlockInlineCodeChrome(canvas, drawFill = false, drawBorder = true) } - private fun drawSlockInlineCodeBackgrounds(canvas: Canvas) { + private fun drawSlockInlineCodeChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { val spanned = textLayout.text as? Spanned ?: return val spans = spanned.getSpans(0, spanned.length, KRSlockInlineCodeSpan::class.java) if (spans.isEmpty()) return @@ -157,8 +158,12 @@ class KRRichTextViewDrawer(val textLayout: Layout) { if (bottom <= top) continue slockInlineCodeRect.set(left, top, right, bottom) - canvas.drawRect(slockInlineCodeRect, slockInlineCodeFillPaint) - canvas.drawSlockInlineCodeBorder(left, top, right, bottom) + if (drawFill) { + canvas.drawRect(slockInlineCodeRect, slockInlineCodeFillPaint) + } + if (drawBorder) { + canvas.drawSlockInlineCodeBorder(left, top, right, bottom) + } } } } From 0ff34632a97302213aef4bf30f7ce474ecd4fb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMP-=E4=B8=93=E5=AE=B6?= Date: Thu, 2 Jul 2026 03:31:31 +0800 Subject: [PATCH 036/187] fix(android): let compose overlays capture native touch dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 --- .../render/android/expand/component/KRView.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt index ce1dd81e3..484c92a0c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt @@ -58,6 +58,7 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp */ private var requestLayoutLogCount = 0 private var onLayoutLogCount = 0 + private var superTouchCaptureLogCount = 0 /** * 嵌套滚动相关 @@ -103,6 +104,7 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private var superTouch: Boolean = false private var superTouchCanceled: Boolean = false + private var superTouchConsumedByCompose: Boolean = false private fun syncComposeRootTag() { krRootView()?.setTag(COMPOSE_ROOT_TAG_ID, superTouch) @@ -259,7 +261,28 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp } // 以下是SuperTouch模式,意味着该View对应Compose的根节点,用于分发Touch事件给Compose + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + superTouchConsumedByCompose = false + } tryFireTouchEvent(event) + if (superTouchConsumedByCompose) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + logSuperTouchCapture("release", event) + superTouchConsumedByCompose = false + } else { + logSuperTouchCapture("continue", event) + } + return true + } + if (event.actionMasked == MotionEvent.ACTION_DOWN && touchDownConsumeOnce) { + // Compose hit-testing already selected a pointer target. Capture the full gesture at + // the root so native children underneath a Compose overlay (for example RichText link + // spans) do not receive the same DOWN and trigger through the overlay. + superTouchConsumedByCompose = true + touchDownConsumeOnce = false + logSuperTouchCapture("capture_down", event) + return true + } var handle = super.dispatchTouchEvent(event) if (handle) { // 子节点已经接接收了,后面的MOVE UP事件都能收到,不用兜底 @@ -502,6 +525,17 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp parentView?.requestedLayout = false } + private fun logSuperTouchCapture(reason: String, event: MotionEvent) { + val parentView = parent as? KuiklyRenderView + if (parentView != null && parentView.isDebugLogEnable() && superTouchCaptureLogCount < SUPER_TOUCH_CAPTURE_MAX_LOG_COUNT) { + KuiklyRenderLog.d( + VIEW_NAME, + "superTouchCapture $superTouchCaptureLogCount reason=$reason action=${event.actionMasked} x=${event.x} y=${event.y} childCount=$childCount" + ) + superTouchCaptureLogCount++ + } + } + private fun getOrCreateTextSelector(): KRTextSelector { return textSelector ?: KRTextSelector(this).also { textSelector = it } } @@ -530,6 +564,7 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private const val EVENT_TOUCH_CANCEL = "touchCancel" private const val EVENT_SCREEN_FRAME = "screenFrame" private const val LAYOUT_MAX_LOG_COUNT = 10 + private const val SUPER_TOUCH_CAPTURE_MAX_LOG_COUNT = 30 private const val ATTR_SELECTABLE = "selectable" private const val ATTR_SELECTION_COLOR = "selectionColor" From 21733ed404a32c52ae0f5813ccd6403ce0a88643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMP-=E4=B8=93=E5=AE=B6?= Date: Thu, 2 Jul 2026 13:13:33 +0800 Subject: [PATCH 037/187] fix(android): restore native dispatch after compose touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 --- .../render/android/expand/component/KRView.kt | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt index 484c92a0c..ce1dd81e3 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt @@ -58,7 +58,6 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp */ private var requestLayoutLogCount = 0 private var onLayoutLogCount = 0 - private var superTouchCaptureLogCount = 0 /** * 嵌套滚动相关 @@ -104,7 +103,6 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private var superTouch: Boolean = false private var superTouchCanceled: Boolean = false - private var superTouchConsumedByCompose: Boolean = false private fun syncComposeRootTag() { krRootView()?.setTag(COMPOSE_ROOT_TAG_ID, superTouch) @@ -261,28 +259,7 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp } // 以下是SuperTouch模式,意味着该View对应Compose的根节点,用于分发Touch事件给Compose - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - superTouchConsumedByCompose = false - } tryFireTouchEvent(event) - if (superTouchConsumedByCompose) { - if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { - logSuperTouchCapture("release", event) - superTouchConsumedByCompose = false - } else { - logSuperTouchCapture("continue", event) - } - return true - } - if (event.actionMasked == MotionEvent.ACTION_DOWN && touchDownConsumeOnce) { - // Compose hit-testing already selected a pointer target. Capture the full gesture at - // the root so native children underneath a Compose overlay (for example RichText link - // spans) do not receive the same DOWN and trigger through the overlay. - superTouchConsumedByCompose = true - touchDownConsumeOnce = false - logSuperTouchCapture("capture_down", event) - return true - } var handle = super.dispatchTouchEvent(event) if (handle) { // 子节点已经接接收了,后面的MOVE UP事件都能收到,不用兜底 @@ -525,17 +502,6 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp parentView?.requestedLayout = false } - private fun logSuperTouchCapture(reason: String, event: MotionEvent) { - val parentView = parent as? KuiklyRenderView - if (parentView != null && parentView.isDebugLogEnable() && superTouchCaptureLogCount < SUPER_TOUCH_CAPTURE_MAX_LOG_COUNT) { - KuiklyRenderLog.d( - VIEW_NAME, - "superTouchCapture $superTouchCaptureLogCount reason=$reason action=${event.actionMasked} x=${event.x} y=${event.y} childCount=$childCount" - ) - superTouchCaptureLogCount++ - } - } - private fun getOrCreateTextSelector(): KRTextSelector { return textSelector ?: KRTextSelector(this).also { textSelector = it } } @@ -564,7 +530,6 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private const val EVENT_TOUCH_CANCEL = "touchCancel" private const val EVENT_SCREEN_FRAME = "screenFrame" private const val LAYOUT_MAX_LOG_COUNT = 10 - private const val SUPER_TOUCH_CAPTURE_MAX_LOG_COUNT = 30 private const val ATTR_SELECTABLE = "selectable" private const val ATTR_SELECTION_COLOR = "selectionColor" From 4ed2bb7374b25592789b238d841d6a31b25deb22 Mon Sep 17 00:00:00 2001 From: Cindy Date: Thu, 2 Jul 2026 13:49:25 +0800 Subject: [PATCH 038/187] fix(compose): serialize rich text span font family Signed-off-by: Cindy --- .../kuikly/compose/foundation/text/KuiklyTextExtension.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 7626f270f..68625add8 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -447,6 +447,7 @@ internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { if (spanStyle.fontSize.isSpecified) { fontSize(scaleToDensity(density, spanStyle.fontSize.value)) } + applyFontFamily(spanStyle.fontFamily) applyFontWeight(spanStyle.fontWeight) applyFontStyle(spanStyle.fontStyle) applyShadow(spanStyle.shadow) From da3d65fbc138c7d3a7692464378d1df3b6f67152 Mon Sep 17 00:00:00 2001 From: Cindy Date: Thu, 2 Jul 2026 14:24:25 +0800 Subject: [PATCH 039/187] fix(android): fake bold rich text fallback glyphs Signed-off-by: Cindy --- .../expand/component/text/KRRichTextBuilder.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 3f1c5c73c..9a76b3589 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -191,8 +191,6 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { kuiklyContext.spToPxI(spanProps.fontSize) })) } - val fontWeightSpan = FontWeightSpan(spanProps.fontWeight, index) - textSpans.add(fontWeightSpan) textSpans.add(StyleSpan(spanProps.fontStyle)) if (spanProps.fontVariant.isNotEmpty()) { textSpans.add(FontVariantSpan(spanProps.fontVariant)) @@ -200,6 +198,8 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.fontFamily.isNotEmpty()) { textSpans.add(FontFamilySpan(spanProps.fontFamily, kuiklyContext?.getTypeFaceLoader())) } + val fontWeightSpan = FontWeightSpan(spanProps.fontWeight, index) + textSpans.add(fontWeightSpan) // 修饰相关 textSpans.add(ForegroundColorSpan(spanProps.color)) @@ -493,8 +493,12 @@ private class KRSlockInlineCodeAtomicTextSpan( class FontWeightSpan(fontWeight: String, val index: Int = -1) : CharacterStyle() { private val strokeWidth = getFontWeight(fontWeight) + private val fakeBold = isBoldWeight(fontWeight) override fun updateDrawState(tp: TextPaint) { + if (fakeBold) { + tp.isFakeBoldText = true + } if (strokeWidth != 0f) { tp.style = Paint.Style.FILL_AND_STROKE tp.strokeWidth = strokeWidth * tp.textSize @@ -528,6 +532,11 @@ class FontWeightSpan(fontWeight: String, val index: Int = -1) : CharacterStyle() else -> FONT_WEIGHT_NORMAL_VALUE } } + + private fun isBoldWeight(fontWeight: String): Boolean = + fontWeight == FONT_WEIGHT_BOLD || + fontWeight == FONT_WEIGHT_EXTRA_BOLD || + fontWeight == FONT_WEIGHT_BLACK } } From bacb648db592dd1bd059bd2984834ae08e96f1f6 Mon Sep 17 00:00:00 2001 From: Cindy Date: Thu, 2 Jul 2026 14:52:05 +0800 Subject: [PATCH 040/187] fix(android): avoid double inline code background Signed-off-by: Cindy --- .../render/android/expand/component/text/KRRichTextBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 9a76b3589..3c614d460 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -203,7 +203,7 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { // 修饰相关 textSpans.add(ForegroundColorSpan(spanProps.color)) - if (spanProps.backgroundColor != Color.TRANSPARENT) { + if (spanProps.backgroundColor != Color.TRANSPARENT && !spanProps.slockInlineCode) { textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) } if (spanProps.textDecoration.isNotEmpty()) { From 9527520453b3e14156d5f4b527ecccb732f19cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMP-=E4=B8=93=E5=AE=B6?= Date: Thu, 2 Jul 2026 15:09:01 +0800 Subject: [PATCH 041/187] fix(android): add explicit native dispatch capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 --- .../compose/container/SuperTouchManager.kt | 8 ++- .../ui/input/pointer/HitPathTracker.kt | 29 +++++++++-- .../ui/input/pointer/NativeDispatchCapture.kt | 51 +++++++++++++++++++ .../pointer/PointerInputEventProcessor.kt | 19 ++++--- .../ui/node/PointerInputModifierNode.kt | 10 ++++ .../render/android/expand/component/KRView.kt | 17 +++++++ .../com/tencent/kuikly/core/base/Attr.kt | 5 ++ 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchCapture.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/container/SuperTouchManager.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/container/SuperTouchManager.kt index 669d22252..1a0ed6e4e 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/container/SuperTouchManager.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/container/SuperTouchManager.kt @@ -66,12 +66,17 @@ class SuperTouchManager { getView()?.getViewAttr()?.forceUpdate = true getView()?.getViewAttr()?.consumeTouchDown(true) } + if (result.nativeDispatchCaptured) { + getView()?.getViewAttr()?.forceUpdate = true + getView()?.getViewAttr()?.nativeDispatchCapture(true) + } } } internal fun DivEvent.setTouchUp(isSync: Boolean) { touchUp(isSync) { touchesDelegate.onTouchesEvent(it.touches, PointerEventType.Release, it.timestamp, it.consumed) + getView()?.getViewAttr()?.nativeDispatchCapture(false) if (container.getViewAttr().getProp(StyleConst.PREVENT_TOUCH) == true) { container.getViewAttr().preventTouch(false) if (useSyncMove) { @@ -98,6 +103,7 @@ class SuperTouchManager { internal fun DivEvent.setTouchCancel(isSync: Boolean) { touchCancel(isSync) { touchesDelegate.onTouchesEvent(it.touches, PointerEventType.Release, it.timestamp, true) + getView()?.getViewAttr()?.nativeDispatchCapture(false) if (container.getViewAttr().getProp(StyleConst.PREVENT_TOUCH) == true) { container.getViewAttr().preventTouch(false) if (useSyncMove) { @@ -190,4 +196,4 @@ class SuperTouchManager { } } -} \ No newline at end of file +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/HitPathTracker.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/HitPathTracker.kt index 837da2545..dc52fbeaa 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/HitPathTracker.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/HitPathTracker.kt @@ -99,7 +99,7 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { fun dispatchChanges( internalPointerEvent: InternalPointerEvent, isInBounds: Boolean = true - ): Boolean { + ): HitPathDispatchResult { val changed = root.buildCache( internalPointerEvent.changes, rootCoordinates, @@ -108,8 +108,9 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { ) if (!changed) { root.cleanUpHover() - return false + return HitPathDispatchResult(dispatched = false, nativeDispatchCaptured = false) } + val nativeDispatchCaptured = root.capturesNativeDispatch() var dispatchHit = root.dispatchMainEventPass( internalPointerEvent.changes, rootCoordinates, @@ -118,7 +119,10 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { ) dispatchHit = root.dispatchFinalEventPass(internalPointerEvent) || dispatchHit - return dispatchHit + return HitPathDispatchResult( + dispatched = dispatchHit, + nativeDispatchCaptured = nativeDispatchCaptured + ) } /** @@ -143,6 +147,11 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { } } +internal data class HitPathDispatchResult( + val dispatched: Boolean, + val nativeDispatchCaptured: Boolean +) + /** * Represents a parent node in the [HitPathTracker]'s tree. This primarily exists because the tree * necessarily has a root that is very similar to all other nodes, except that it does not track any @@ -217,6 +226,9 @@ internal open class NodeParent { return dispatched } + open fun capturesNativeDispatch(): Boolean = + children.any { it.capturesNativeDispatch() } + /** * Dispatches the cancel event to all child [Node]s. */ @@ -361,6 +373,17 @@ internal class Node(val modifierNode: Modifier.Node) : NodeParent() { return result } + override fun capturesNativeDispatch(): Boolean { + if (relevantChanges.isEmpty() || !modifierNode.isAttached) { + return super.capturesNativeDispatch() + } + var captures = false + modifierNode.dispatchForKind(Nodes.PointerInput) { + captures = captures || it.captureNativeDispatch() + } + return captures || super.capturesNativeDispatch() + } + /** * Calculates cached properties that will be stored in this [Node] for the duration of both * [dispatchMainEventPass] and [dispatchFinalEventPass]. This allows us to avoid repeated diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchCapture.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchCapture.kt new file mode 100644 index 000000000..bb2edfe44 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchCapture.kt @@ -0,0 +1,51 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.input.pointer + +import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.node.ModifierNodeElement +import com.tencent.kuikly.compose.ui.node.PointerInputModifierNode +import com.tencent.kuikly.compose.ui.unit.IntSize + +/** + * Marks this pointer region as an explicit native-dispatch capture boundary. + * + * Use this only for overlay/barrier surfaces that must prevent Android native + * child views underneath the Compose root from receiving the same MotionEvent. + */ +fun Modifier.nativeDispatchCapture(): Modifier = this.then(NativeDispatchCaptureElement) + +private object NativeDispatchCaptureElement : ModifierNodeElement() { + override fun create(): NativeDispatchCaptureNode = NativeDispatchCaptureNode() + + override fun update(node: NativeDispatchCaptureNode) = Unit + + override fun hashCode(): Int = NativeDispatchCaptureElement::class.hashCode() + + override fun equals(other: Any?): Boolean = other === this +} + +private class NativeDispatchCaptureNode : Modifier.Node(), PointerInputModifierNode { + override fun captureNativeDispatch(): Boolean = true + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) = Unit + + override fun onCancelPointerInput() = Unit +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/PointerInputEventProcessor.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/PointerInputEventProcessor.kt index abbf3bea7..54911ad4f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/PointerInputEventProcessor.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/PointerInputEventProcessor.kt @@ -133,7 +133,7 @@ internal class PointerInputEventProcessor(val root: LayoutNode) { } // Dispatch to PointerInputFilters - val dispatchedToSomething = + val dispatchResult = hitPathTracker.dispatchChanges(internalPointerEvent, isInBounds) val anyMovementConsumed = if (internalPointerEvent.suppressMovementConsumption) { @@ -149,11 +149,13 @@ internal class PointerInputEventProcessor(val root: LayoutNode) { } result } - val processResult = ProcessResult(dispatchedToSomething, - anyMovementConsumed + val processResult = ProcessResult( + dispatchedToAPointerInputModifier = dispatchResult.dispatched, + anyMovementConsumed = anyMovementConsumed, + nativeDispatchCaptured = dispatchResult.nativeDispatchCaptured ) - if (pointerEvent.eventType == PointerEventType.Press && !dispatchedToSomething) { + if (pointerEvent.eventType == PointerEventType.Press && !dispatchResult.dispatched) { pointerInputChangeEventProducer.clear() } @@ -273,6 +275,9 @@ value class ProcessResult(private val value: Int) { val anyMovementConsumed get() = (value and (1 shl 1)) != 0 + + val nativeDispatchCaptured + get() = (value and (1 shl 2)) != 0 } /** @@ -284,9 +289,11 @@ value class ProcessResult(private val value: Int) { */ internal fun ProcessResult( dispatchedToAPointerInputModifier: Boolean, - anyMovementConsumed: Boolean + anyMovementConsumed: Boolean, + nativeDispatchCaptured: Boolean = false ): ProcessResult { val val1 = if (dispatchedToAPointerInputModifier) 1 else 0 val val2 = if (anyMovementConsumed) (1 shl 1) else 0 - return ProcessResult(val1 or val2) + val val3 = if (nativeDispatchCaptured) (1 shl 2) else 0 + return ProcessResult(val1 or val2 or val3) } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/PointerInputModifierNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/PointerInputModifierNode.kt index 62bed7383..8641a2f10 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/PointerInputModifierNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/PointerInputModifierNode.kt @@ -83,6 +83,16 @@ interface PointerInputModifierNode : DelegatableNode { */ fun sharePointerInputWithSiblings(): Boolean = false + /** + * Return true when this node intentionally wants the host render root to + * capture native child dispatch for the current touch gesture. + * + * This is narrower than "a pointer input node was hit": ordinary click, + * scroll, and text-input modifiers must keep returning false so platform + * native children still receive their expected MotionEvents. + */ + fun captureNativeDispatch(): Boolean = false + /** * Invoked when the density (pixels per inch for the screen) changes. This can impact the * location of pointer input events (x and y) and can affect things like touch slop detection. diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt index ce1dd81e3..d84e92726 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRView.kt @@ -103,6 +103,8 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private var superTouch: Boolean = false private var superTouchCanceled: Boolean = false + private var nativeDispatchCaptureRequested: Boolean = false + private var nativeDispatchCapturedGesture: Boolean = false private fun syncComposeRootTag() { krRootView()?.setTag(COMPOSE_ROOT_TAG_ID, superTouch) @@ -119,6 +121,10 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp syncComposeRootTag() true } + NATIVE_DISPATCH_CAPTURE -> { + nativeDispatchCaptureRequested = propValue as Boolean + true + } EVENT_TOUCH_DOWN -> { touchDownCallback = propValue as KuiklyRenderCallback true @@ -260,6 +266,16 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp // 以下是SuperTouch模式,意味着该View对应Compose的根节点,用于分发Touch事件给Compose tryFireTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + nativeDispatchCapturedGesture = nativeDispatchCaptureRequested + nativeDispatchCaptureRequested = false + } + if (nativeDispatchCapturedGesture) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + nativeDispatchCapturedGesture = false + } + return true + } var handle = super.dispatchTouchEvent(event) if (handle) { // 子节点已经接接收了,后面的MOVE UP事件都能收到,不用兜底 @@ -524,6 +540,7 @@ open class KRView(context: Context) : FrameLayout(context), IKuiklyRenderViewExp private const val EVENT_ACTION = "action" private const val SUPER_TOUCH = "superTouch" + private const val NATIVE_DISPATCH_CAPTURE = "nativeDispatchCapture" private const val EVENT_TOUCH_DOWN = "touchDown" private const val EVENT_TOUCH_MOVE = "touchMove" private const val EVENT_TOUCH_UP = "touchUp" diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/Attr.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/Attr.kt index 30cb4cd63..0ea2ebd02 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/Attr.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/Attr.kt @@ -676,6 +676,10 @@ open class Attr : Props(), IStyleAttr, ILayoutAttr { StyleConst.CONSUME_DOWN with enable } + fun nativeDispatchCapture(enable: Boolean) { + StyleConst.NATIVE_DISPATCH_CAPTURE with enable + } + fun superTouch(enable: Boolean) { StyleConst.SUPER_TOUCH with enable } @@ -716,6 +720,7 @@ open class Attr : Props(), IStyleAttr, ILayoutAttr { const val DEBUG_NAME = "debugName" const val PREVENT_TOUCH = "preventTouch" const val CONSUME_DOWN = "consumeDown" + const val NATIVE_DISPATCH_CAPTURE = "nativeDispatchCapture" const val SUPER_TOUCH = "superTouch" // glass effect From 703408a6ab257eeeee59b76927c654e3d6fea6fe Mon Sep 17 00:00:00 2001 From: Cindy Date: Thu, 2 Jul 2026 16:56:57 +0800 Subject: [PATCH 042/187] fix(android): tighten inline code horizontal padding Signed-off-by: Cindy --- .../render/android/expand/component/text/KRRichTextBuilder.kt | 2 +- .../android/expand/component/text/KRRichTextViewDrawer.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 3c614d460..dcd681586 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -57,7 +57,7 @@ import org.json.JSONObject import kotlin.math.ceil import kotlin.math.max -private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f /** diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index bad0bcf01..2d7b7f41c 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -37,7 +37,7 @@ import kotlin.math.min private const val INVALID_OFFSET = -1 private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() -private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 7f / 15f +private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f From 3509beef4369ed633093d769010b9ce57a1a1107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMP-=E4=B8=93=E5=AE=B6?= Date: Fri, 3 Jul 2026 05:19:04 +0800 Subject: [PATCH 043/187] fix(ohos): support native dispatch capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 --- compose/build.2.0.ohos.gradle.kts | 4 +-- .../expand/components/view/KRView.cpp | 35 +++++++++++++++++++ .../expand/components/view/KRView.h | 2 ++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/compose/build.2.0.ohos.gradle.kts b/compose/build.2.0.ohos.gradle.kts index 986fb4ac0..80b210f13 100644 --- a/compose/build.2.0.ohos.gradle.kts +++ b/compose/build.2.0.ohos.gradle.kts @@ -61,7 +61,7 @@ kotlin { api("com.tencent.kuikly-open.compose.runtime:runtime-saveable:1.7.3-kuikly1") api("com.tencent.kuikly-open.compose.annotation-internal:annotation:1.7.3-kuikly1") api("com.tencent.kuikly-open.compose.collection-internal:collection:1.7.3-kuikly1") - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-KBA-001") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-KBA-002") api("org.jetbrains.kotlinx:atomicfu:0.23.2-KBA-001") } @@ -109,4 +109,4 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } -} \ No newline at end of file +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp index bd2811329..15a0fb3b6 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp @@ -58,6 +58,7 @@ constexpr char kPropNameTouchMove[] = "touchMove"; constexpr char kPropNameTouchUp[] = "touchUp"; constexpr char kPropNameTouchCancel[] = "touchCancel"; constexpr char kPropNamePreventTouch[] = "preventTouch"; +constexpr char kPropNameNativeDispatchCapture[] = "nativeDispatchCapture"; constexpr char kPropNameSuperTouch[] = "superTouch"; constexpr char kPropNameHitTestModeOhos[] = "hit-test-ohos"; constexpr char kPropNameStopPropagation[] = "stop-propagation-ohos"; @@ -118,6 +119,9 @@ bool KRView::SetProp(const std::string &prop_key, const KRAnyValue &prop_value, super_touch_handler_->PreventTouch(prop_value->toBool()); } didHand = true; + } else if (kuikly::util::isEqual(prop_key, kPropNameNativeDispatchCapture)) { + native_dispatch_capture_requested_ = prop_value->toBool(); + didHand = true; } else if (kuikly::util::isEqual(prop_key, kPropNameSuperTouch)) { if (prop_value->toBool()) { if (!super_touch_handler_) { @@ -394,7 +398,16 @@ bool KRView::ResetProp(const std::string &prop_key) { } else if (kuikly::util::isEqual(prop_key, kPropNamePreventTouch)) { // reset handled by kPropNameSuperTouch, do nothing here didHande = true; + } else if (kuikly::util::isEqual(prop_key, kPropNameNativeDispatchCapture)) { + native_dispatch_capture_requested_ = false; + native_dispatch_captured_gesture_ = false; + if (super_touch_handler_) { + super_touch_handler_->ClearNativeTouchConsumer(shared_from_this()); + } + didHande = true; } else if (kuikly::util::isEqual(prop_key, kPropNameSuperTouch)) { + native_dispatch_capture_requested_ = false; + native_dispatch_captured_gesture_ = false; super_touch_handler_ = nullptr; didHande = true; } else if (kuikly::util::isEqual(prop_key, kPropNameHitTestModeOhos)) { @@ -447,6 +460,23 @@ void KRView::ProcessTouchEvent(ArkUI_NodeEvent *event) { handled = TryFireOnTouchCancelEvent(input_event); } if (super_touch_type_ == SELF) { + if (action == UI_TOUCH_EVENT_ACTION_DOWN) { + native_dispatch_captured_gesture_ = native_dispatch_capture_requested_; + native_dispatch_capture_requested_ = false; + if (native_dispatch_captured_gesture_ && super_touch_handler_) { + super_touch_handler_->SetNativeTouchConsumer(shared_from_this()); + } + } + if (native_dispatch_captured_gesture_) { + if (action == UI_TOUCH_EVENT_ACTION_UP || action == UI_TOUCH_EVENT_ACTION_CANCEL) { + native_dispatch_captured_gesture_ = false; + if (super_touch_handler_) { + super_touch_handler_->ClearNativeTouchConsumer(shared_from_this()); + } + } + kuikly::util::StopPropagation(event); + return; + } if (super_touch_handler_->GetStopPropagation(action)) { kuikly::util::StopPropagation(event); super_touch_handler_->SetStopPropagation(action, false); @@ -615,6 +645,11 @@ void KRView::UpdateHitTestMode(bool shouldUseTarget) { void KRView::WillRemoveFromParentView() { IKRRenderViewExport::WillRemoveFromParentView(); + if (super_touch_handler_) { + super_touch_handler_->ClearNativeTouchConsumer(shared_from_this()); + } + native_dispatch_capture_requested_ = false; + native_dispatch_captured_gesture_ = false; parent_super_touch_handler_.reset(); super_touch_type_ = UNKNOWN; } diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h index 7f408be8c..073e9f3a8 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h @@ -113,6 +113,8 @@ class KRView : public IKRRenderViewExport { std::weak_ptr parent_super_touch_handler_; SuperTouchType super_touch_type_ = UNKNOWN; bool stop_propagation_ = false; + bool native_dispatch_capture_requested_ = false; + bool native_dispatch_captured_gesture_ = false; SelectableOption selectable_option_ = SelectableOption::ENABLE; From 1fd3fe2d03fffbbbd91cc4edc92a3ab450ad6877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KMP-=E4=B8=93=E5=AE=B6?= Date: Fri, 3 Jul 2026 15:34:37 +0800 Subject: [PATCH 044/187] fix(compose): add pager keep item alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 --- .../foundation/pager/LazyLayoutPager.kt | 13 +++++++-- .../kuikly/compose/foundation/pager/Pager.kt | 8 ++++++ .../pages/compose/HorizontalPagerDemo1.kt | 28 +++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/LazyLayoutPager.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/LazyLayoutPager.kt index ddc57fb9e..2aef5b226 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/LazyLayoutPager.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/LazyLayoutPager.kt @@ -76,6 +76,8 @@ internal fun Pager( userScrollEnabled: Boolean, /** Number of pages to compose and layout before and after the visible pages */ beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, + /** Whether all pages should remain composed and laid out even when offscreen. */ + keepItemAlive: Boolean = false, /** Space between pages */ pageSpacing: Dp = 0.dp, /** Allows to change how to calculate the Page size */ @@ -98,6 +100,13 @@ internal fun Pager( "you selected $beyondViewportPageCount" } + val effectiveBeyondViewportPageCount = + if (keepItemAlive) { + maxOf(beyondViewportPageCount, state.pageCount - 1) + } else { + beyondViewportPageCount + } + state.contentPadding = contentPadding val pagerItemProvider = rememberPagerItemProviderLambda( state = state, @@ -113,7 +122,7 @@ internal fun Pager( contentPadding = contentPadding, reverseLayout = reverseLayout, orientation = orientation, - beyondViewportPageCount = beyondViewportPageCount, + beyondViewportPageCount = effectiveBeyondViewportPageCount, pageSpacing = pageSpacing, pageSize = pageSize, horizontalAlignment = horizontalAlignment, @@ -161,7 +170,7 @@ internal fun Pager( .lazyLayoutBeyondBoundsModifier( state = rememberPagerBeyondBoundsState( state = state, - beyondViewportPageCount = beyondViewportPageCount + beyondViewportPageCount = effectiveBeyondViewportPageCount ), beyondBoundsInfo = state.beyondBoundsInfo, reverseLayout = reverseLayout, diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/Pager.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/Pager.kt index e4b0dbb16..57f5cb12e 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/Pager.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/Pager.kt @@ -87,6 +87,8 @@ import kotlin.math.roundToInt * position will be maintained based on the key, which means if you add/remove items before the * current visible item the item with the given key will be kept as the first visible one. If null * is passed the position in the list will represent the key. + * @param keepItemAlive whether all pages should stay composed and laid out even when outside the + * visible viewport, matching PageList's `keepItemAlive(true)` semantics for small tab/page sets. * @param pageNestedScrollConnection A [NestedScrollConnection] that dictates how this [Pager] * behaves with nested lists. The default behavior will see [Pager] to consume all nested deltas. * @param snapPosition The calculation of how this Pager will perform snapping of pages. @@ -109,6 +111,7 @@ fun HorizontalPager( userScrollEnabled: Boolean = true, // reverseLayout: Boolean = false, key: ((index: Int) -> Any)? = null, + keepItemAlive: Boolean = false, // pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( // state, // Orientation.Horizontal @@ -130,6 +133,7 @@ fun HorizontalPager( userScrollEnabled = userScrollEnabled, // reverseLayout = reverseLayout, key = key, + keepItemAlive = keepItemAlive, // pageNestedScrollConnection = pageNestedScrollConnection, snapPosition = SnapPosition.Start, pageContent = pageContent @@ -174,6 +178,8 @@ fun HorizontalPager( * position will be maintained based on the key, which means if you add/remove items before the * current visible item the item with the given key will be kept as the first visible one. If null * is passed the position in the list will represent the key. + * @param keepItemAlive whether all pages should stay composed and laid out even when outside the + * visible viewport, matching PageList's `keepItemAlive(true)` semantics for small tab/page sets. * @param pageNestedScrollConnection A [NestedScrollConnection] that dictates how this [Pager] behaves * with nested lists. The default behavior will see [Pager] to consume all nested deltas. * @param snapPosition The calculation of how this Pager will perform snapping of Pages. @@ -196,6 +202,7 @@ fun VerticalPager( userScrollEnabled: Boolean = true, // reverseLayout: Boolean = false, key: ((index: Int) -> Any)? = null, + keepItemAlive: Boolean = false, // pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( // state, // Orientation.Vertical @@ -217,6 +224,7 @@ fun VerticalPager( userScrollEnabled = userScrollEnabled, // reverseLayout = reverseLayout, key = key, + keepItemAlive = keepItemAlive, // pageNestedScrollConnection = pageNestedScrollConnection, snapPosition = SnapPosition.Start, pageContent = pageContent diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/HorizontalPagerDemo1.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/HorizontalPagerDemo1.kt index e06379ed5..9cac27143 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/HorizontalPagerDemo1.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/HorizontalPagerDemo1.kt @@ -204,7 +204,31 @@ class HorizontalPagerDemo1 : ComposeContainer() { Spacer(Modifier.height(20.dp)) - // 7. 测试 userScrollEnabled + // 7. 测试 keepItemAlive + Text("7. keepItemAlive = true:") + HorizontalPager( + state = rememberPagerState { 3 }, + modifier = + Modifier + .height(100.dp) + .background(Color.LightGray), + keepItemAlive = true, + ) { page -> + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Yellow) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Text("Alive Page $page") + } + } + + Spacer(Modifier.height(20.dp)) + + // 8. 测试 userScrollEnabled var scrollEnabled by remember { mutableStateOf(true) } Box( modifier = @@ -212,7 +236,7 @@ class HorizontalPagerDemo1 : ComposeContainer() { scrollEnabled = !scrollEnabled }, ) { - Text("7. 点击切换滚动状态 (userScrollEnabled = $scrollEnabled):") + Text("8. 点击切换滚动状态 (userScrollEnabled = $scrollEnabled):") } HorizontalPager( state = rememberPagerState { 5 }, From 3bd6a618c3f72074aa2ed32f1ec4901e8cba798e Mon Sep 17 00:00:00 2001 From: artin Date: Sat, 4 Jul 2026 23:17:21 +0800 Subject: [PATCH 045/187] fix(richtext): support custom underline style Signed-off-by: KMP Expert --- .../foundation/text/KuiklyTextExtension.kt | 9 ++ .../kuikly/compose/ui/text/SpanStyle.kt | 83 +++++++++++++++++++ .../component/text/KRRichTextBuilder.kt | 80 ++++++++++++++++++ .../Extension/AdvancedComps/KRRichTextView.h | 3 + .../Extension/AdvancedComps/KRRichTextView.m | 12 ++- .../components/richtext/KRParagraph.cpp | 13 ++- .../components/richtext/KRRichTextShadow.cpp | 11 +++ .../tencent/kuikly/core/views/RichTextView.kt | 15 ++++ .../com/tencent/kuikly/core/views/TextView.kt | 18 ++++ 9 files changed, 242 insertions(+), 2 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 68625add8..676ea09e6 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -472,6 +472,15 @@ internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { // Apply text decoration spanStyle.textDecoration?.let { applyTextDecoration(it) } + if (spanStyle.textDecorationColor.isSpecified) { + setProp(TextConst.TEXT_DECORATION_COLOR, spanStyle.textDecorationColor.toKuiklyColor().toString()) + } + if (spanStyle.textDecorationThickness.isSpecified) { + setProp(TextConst.TEXT_DECORATION_THICKNESS, scaleToDensity(density, spanStyle.textDecorationThickness.value)) + } + if (spanStyle.textDecorationOffset.isSpecified) { + setProp(TextConst.TEXT_DECORATION_OFFSET, scaleToDensity(density, spanStyle.textDecorationOffset.value)) + } // Apply letter spacing if (spanStyle.letterSpacing.isSpecified) { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt index e1b009dd5..b32a70bf5 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt @@ -75,6 +75,9 @@ private val DefaultColor = Color.Black * @param background The background color for the text. * @param textDecoration The decorations to paint on the text (e.g., an underline). * @param shadow The shadow effect applied on the text. + * @param textDecorationColor The color used for text decorations such as underlines. + * @param textDecorationThickness The thickness used for text decorations such as underlines. + * @param textDecorationOffset The baseline offset used for text decorations such as underlines. * @param platformStyle Platform specific [SpanStyle] parameters. * @param drawStyle Drawing style of text, whether fill in the text while drawing or stroke around * the edges. @@ -100,6 +103,9 @@ class SpanStyle internal constructor( val background: Color = Color.Unspecified, // kuikly暂时不支持 val textDecoration: TextDecoration? = null, val shadow: Shadow? = null, + val textDecorationColor: Color = Color.Unspecified, + val textDecorationThickness: TextUnit = TextUnit.Unspecified, + val textDecorationOffset: TextUnit = TextUnit.Unspecified, // val platformStyle: PlatformSpanStyle? = null, // val drawStyle: DrawStyle? = null ) { @@ -131,6 +137,9 @@ class SpanStyle internal constructor( * @param background The background color for the text. * @param textDecoration The decorations to paint on the text (e.g., an underline). * @param shadow The shadow effect applied on the text. + * @param textDecorationColor The color used for text decorations such as underlines. + * @param textDecorationThickness The thickness used for text decorations such as underlines. + * @param textDecorationOffset The baseline offset used for text decorations such as underlines. * @param platformStyle Platform specific [SpanStyle] parameters. * @param drawStyle Drawing style of text, whether fill in the text while drawing or stroke * around the edges. @@ -154,6 +163,9 @@ class SpanStyle internal constructor( background: Color = Color.Unspecified, textDecoration: TextDecoration? = null, shadow: Shadow? = null, + textDecorationColor: Color = Color.Unspecified, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle? = null, // drawStyle: DrawStyle? = null ) : this( @@ -171,6 +183,9 @@ class SpanStyle internal constructor( background = background, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -206,6 +221,9 @@ class SpanStyle internal constructor( * @param background The background color for the text. * @param textDecoration The decorations to paint on the text (e.g., an underline). * @param shadow The shadow effect applied on the text. + * @param textDecorationColor The color used for text decorations such as underlines. + * @param textDecorationThickness The thickness used for text decorations such as underlines. + * @param textDecorationOffset The baseline offset used for text decorations such as underlines. * @param platformStyle Platform specific [SpanStyle] parameters. * @param drawStyle Drawing style of text, whether fill in the text while drawing or stroke * around the edges. @@ -230,6 +248,9 @@ class SpanStyle internal constructor( background: Color = Color.Unspecified, textDecoration: TextDecoration? = null, shadow: Shadow? = null, + textDecorationColor: Color = Color.Unspecified, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle? = null, // drawStyle: DrawStyle? = null ) : this( @@ -247,6 +268,9 @@ class SpanStyle internal constructor( background = background, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -296,6 +320,9 @@ class SpanStyle internal constructor( background = other.background, textDecoration = other.textDecoration, shadow = other.shadow, + textDecorationColor = other.textDecorationColor, + textDecorationThickness = other.textDecorationThickness, + textDecorationOffset = other.textDecorationOffset, // platformStyle = other.platformStyle, // drawStyle = other.drawStyle ) @@ -322,6 +349,9 @@ class SpanStyle internal constructor( background: Color = this.background, textDecoration: TextDecoration? = this.textDecoration, shadow: Shadow? = this.shadow, + textDecorationColor: Color = this.textDecorationColor, + textDecorationThickness: TextUnit = this.textDecorationThickness, + textDecorationOffset: TextUnit = this.textDecorationOffset, // platformStyle: PlatformSpanStyle? = this.platformStyle, // drawStyle: DrawStyle? = this.drawStyle ): SpanStyle { @@ -344,6 +374,9 @@ class SpanStyle internal constructor( background = background, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -365,6 +398,9 @@ class SpanStyle internal constructor( background: Color = this.background, textDecoration: TextDecoration? = this.textDecoration, shadow: Shadow? = this.shadow, + textDecorationColor: Color = this.textDecorationColor, + textDecorationThickness: TextUnit = this.textDecorationThickness, + textDecorationOffset: TextUnit = this.textDecorationOffset, // platformStyle: PlatformSpanStyle? = this.platformStyle, // drawStyle: DrawStyle? = this.drawStyle ): SpanStyle { @@ -383,6 +419,9 @@ class SpanStyle internal constructor( background = background, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -415,6 +454,9 @@ class SpanStyle internal constructor( internal fun hasSameNonLayoutAttributes(other: SpanStyle): Boolean { if (textForegroundStyle != other.textForegroundStyle) return false if (textDecoration != other.textDecoration) return false + if (textDecorationColor != other.textDecorationColor) return false + if (textDecorationThickness != other.textDecorationThickness) return false + if (textDecorationOffset != other.textDecorationOffset) return false if (shadow != other.shadow) return false // if (drawStyle != other.drawStyle) return false return true @@ -436,6 +478,9 @@ class SpanStyle internal constructor( // result = 31 * result + (localeList?.hashCode() ?: 0) result = 31 * result + background.hashCode() result = 31 * result + (textDecoration?.hashCode() ?: 0) + result = 31 * result + textDecorationColor.hashCode() + result = 31 * result + textDecorationThickness.hashCode() + result = 31 * result + textDecorationOffset.hashCode() result = 31 * result + (shadow?.hashCode() ?: 0) // result = 31 * result + (platformStyle?.hashCode() ?: 0) // result = 31 * result + (drawStyle?.hashCode() ?: 0) @@ -478,6 +523,9 @@ class SpanStyle internal constructor( // append("localeList=$localeList, ") append("background=$background, ") append("textDecoration=$textDecoration, ") + append("textDecorationColor=$textDecorationColor, ") + append("textDecorationThickness=$textDecorationThickness, ") + append("textDecorationOffset=$textDecorationOffset, ") append("shadow=$shadow, ") // append("platformStyle=$platformStyle, ") // append("drawStyle=$drawStyle") @@ -569,6 +617,21 @@ fun lerp(start: SpanStyle, stop: SpanStyle, fraction: Float): SpanStyle { stop.textDecoration, fraction ), + textDecorationColor = lerp( + start.textDecorationColor, + stop.textDecorationColor, + fraction + ), + textDecorationThickness = lerpTextUnitInheritable( + start.textDecorationThickness, + stop.textDecorationThickness, + fraction + ), + textDecorationOffset = lerpTextUnitInheritable( + start.textDecorationOffset, + stop.textDecorationOffset, + fraction + ), shadow = lerp( start.shadow ?: Shadow(), stop.shadow ?: Shadow(), @@ -614,6 +677,9 @@ internal fun resolveSpanStyleDefaults(style: SpanStyle) = SpanStyle( // localeList = style.localeList ?: LocaleList.current, background = style.background.takeOrElse { DefaultBackgroundColor }, textDecoration = style.textDecoration ?: TextDecoration.None, + textDecorationColor = style.textDecorationColor, + textDecorationThickness = style.textDecorationThickness, + textDecorationOffset = style.textDecorationOffset, shadow = style.shadow ?: Shadow.None, // platformStyle = style.platformStyle, // drawStyle = style.drawStyle ?: Fill @@ -636,6 +702,9 @@ internal fun SpanStyle.fastMerge( background: Color, textDecoration: TextDecoration?, shadow: Shadow?, + textDecorationColor: Color = Color.Unspecified, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle?, // drawStyle: DrawStyle? ): SpanStyle { @@ -661,6 +730,9 @@ internal fun SpanStyle.fastMerge( fontFamily != null && fontFamily !== this.fontFamily || letterSpacing.isSpecified && letterSpacing != this.letterSpacing || textDecoration != null && textDecoration != this.textDecoration || + textDecorationColor.isSpecified && textDecorationColor != this.textDecorationColor || + textDecorationThickness.isSpecified && textDecorationThickness != this.textDecorationThickness || + textDecorationOffset.isSpecified && textDecorationOffset != this.textDecorationOffset || // then compare the remaining params, for potential non-Text merges brush != textForegroundStyle.brush || brush != null && alpha != this.textForegroundStyle.alpha || @@ -704,6 +776,17 @@ internal fun SpanStyle.fastMerge( // localeList = localeList ?: this.localeList, background = background.takeOrElse { this.background }, textDecoration = textDecoration ?: this.textDecoration, + textDecorationColor = textDecorationColor.takeOrElse { this.textDecorationColor }, + textDecorationThickness = if (!textDecorationThickness.isUnspecified) { + textDecorationThickness + } else { + this.textDecorationThickness + }, + textDecorationOffset = if (!textDecorationOffset.isUnspecified) { + textDecorationOffset + } else { + this.textDecorationOffset + }, shadow = shadow ?: this.shadow, // platformStyle = mergePlatformStyle(platformStyle), // drawStyle = drawStyle ?: this.drawStyle diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index dcd681586..a94006a72 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -209,6 +209,18 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.textDecoration.isNotEmpty()) { if (spanProps.textDecoration == KRTextProps.TEXT_DECORATION_LINE_THROUGH) { textSpans.add(StrikethroughSpan()) + } else if ( + spanProps.textDecorationColor != null || + spanProps.textDecorationThickness != null || + spanProps.textDecorationOffset != null + ) { + textSpans.add( + KRCustomUnderlineSpan( + color = spanProps.textDecorationColor, + thickness = spanProps.textDecorationThickness, + offset = spanProps.textDecorationOffset + ) + ) } else { textSpans.add(UnderlineSpan()) } @@ -265,6 +277,9 @@ class TextSpanProps( val fontStyle: Int val letterSpacing: Float val textDecoration: String + val textDecorationColor: Int? + val textDecorationThickness: Float? + val textDecorationOffset: Float? val lineHeight: Float val backgroundImage: String val backgroundColor: Int @@ -309,6 +324,20 @@ class TextSpanProps( defaultProps.letterSpacing } textDecoration = spanValue.optString(KRTextProps.PROP_KEY_TEXT_DECORATION, defaultProps.textDecoration) + textDecorationColor = + spanValue.optString(TextConst.TEXT_DECORATION_COLOR) + .takeIf { it.isNotEmpty() } + ?.toColor() + textDecorationThickness = + spanValue.optDouble(TextConst.TEXT_DECORATION_THICKNESS, 0.0) + .toFloat() + .takeIf { it > 0f } + ?.let { kuiklyContext.toPxF(it) } + textDecorationOffset = + spanValue.optDouble(TextConst.TEXT_DECORATION_OFFSET, 0.0) + .toFloat() + .takeIf { it != 0f } + ?.let { kuiklyContext.toPxF(it) } lineHeight = if (spanValue.has(KRTextProps.PROP_KEY_LINE_HEIGHT)) { kuiklyContext.toPxF(spanValue.optDouble(KRTextProps.PROP_KEY_LINE_HEIGHT).toFloat()) } else { @@ -357,6 +386,57 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan +private class KRCustomUnderlineSpan( + private val color: Int?, + private val thickness: Float?, + private val offset: Float? +) : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int = + if (text == null || start >= end) { + 0 + } else { + ceil(paint.measureText(text, start, end).toDouble()).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text == null || start >= end) return + canvas.drawText(text, start, end, x, y.toFloat(), paint) + + val lineWidth = paint.measureText(text, start, end) + val previousColor = paint.color + val previousStrokeWidth = paint.strokeWidth + val previousStyle = paint.style + val previousAntiAlias = paint.isAntiAlias + paint.color = color ?: previousColor + paint.strokeWidth = thickness ?: max(1f, previousStrokeWidth) + paint.style = Paint.Style.STROKE + paint.isAntiAlias = true + val underlineY = y.toFloat() + (offset ?: max(1f, paint.strokeWidth)) + canvas.drawLine(x, underlineY, x + lineWidth, underlineY, paint) + paint.color = previousColor + paint.strokeWidth = previousStrokeWidth + paint.style = previousStyle + paint.isAntiAlias = previousAntiAlias + } +} + private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { var index = start var firstAtom = true diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h index 3cf3757f8..49416f1ad 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h @@ -52,6 +52,9 @@ extern NSString *const KuiklyIndexAttributeName; @property (nonatomic, copy) NSString *cssGradient; @property (nonatomic, assign) CGFloat letterSpacing; @property (nonatomic, assign) KRTextDecorationLineType textDecoration; +@property (nonatomic, strong) UIColor *textDecorationColor; +@property (nonatomic, strong) NSNumber *textDecorationThickness; +@property (nonatomic, strong) NSNumber *textDecorationOffset; @property (nonatomic, assign) NSTextAlignment textAlign; @property (nonatomic, strong) NSNumber *lineSpacing; @property (nonatomic, strong) NSNumber *lineHeight; diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index 500f62a09..93c8a7863 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -253,6 +253,9 @@ - (NSMutableAttributedString *)p_buildAttributedString { CGFloat letterSpacing = [KRConvertUtil CGFloat:propStyle[@"letterSpacing"]]; KRTextDecorationLineType textDecoration = [KRConvertUtil KRTextDecorationLineType:propStyle[@"textDecoration"]]; + UIColor *textDecorationColor = [UIView css_color:propStyle[@"textDecorationColor"]]; + NSNumber *textDecorationThickness = propStyle[@"textDecorationThickness"] ? @([KRConvertUtil CGFloat:propStyle[@"textDecorationThickness"]]) : nil; + NSNumber *textDecorationOffset = propStyle[@"textDecorationOffset"] ? @([KRConvertUtil CGFloat:propStyle[@"textDecorationOffset"]]) : nil; NSTextAlignment textAlign = [KRConvertUtil NSTextAlignment:propStyle[@"textAlign"]]; NSNumber *lineHeight = nil; NSNumber *lineSpacing = nil; @@ -302,6 +305,9 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.cssGradient = cssGricent; spanAttrs.letterSpacing = letterSpacing; spanAttrs.textDecoration = textDecoration; + spanAttrs.textDecorationColor = textDecorationColor; + spanAttrs.textDecorationThickness = textDecorationThickness; + spanAttrs.textDecorationOffset = textDecorationOffset; spanAttrs.textAlign = textAlign; spanAttrs.lineSpacing = lineSpacing; spanAttrs.lineHeight = lineHeight; @@ -378,7 +384,11 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut } if (attrs.textDecoration == KRTextDecorationLineTypeUnderline) { - [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:range]; + NSUnderlineStyle underlineStyle = attrs.textDecorationThickness ? NSUnderlineStyleThick : NSUnderlineStyleSingle; + [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(underlineStyle) range:range]; + if (attrs.textDecorationColor) { + [attributedString addAttribute:NSUnderlineColorAttributeName value:attrs.textDecorationColor range:range]; + } } if (attrs.textDecoration == KRTextDecorationLineTypeStrikethrough) { [attributedString addAttribute:NSStrikethroughStyleAttributeName value:@(NSUnderlineStyleSingle) range:range]; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRParagraph.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRParagraph.cpp index 96666cb17..7f0708207 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRParagraph.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRParagraph.cpp @@ -269,6 +269,9 @@ void KRParagraph::AddSpanToStyledString(const KRRenderValue::Map &spanMap, ArkUI auto lineSpacing = GetKTValue("lineSpacing", spanMap, props_)->toFloat() / (fontSize / dpi); // 行间距比例 auto textAlign = kuikly::util::ConvertToTextAlign(GetKTValue("textAlign", spanMap, props_)->toString()); auto textDecoration = kuikly::util::ConvertToTextDecoration(GetKTValue("textDecoration", spanMap, props_)->toString()); + auto textDecorationColorStr = GetKTValue("textDecorationColor", spanMap, props_)->toString(); + auto textDecorationColor = textDecorationColorStr.length() ? kuikly::util::ConvertToHexColor(textDecorationColorStr) : color; + auto textDecorationThickness = GetKTValue("textDecorationThickness", spanMap, props_)->toFloat(); auto fontStyle = kuikly::util::ConvertToFontStyle(GetKTValue("fontStyle", spanMap, props_)->toString()); auto letterSpacing = GetKTValue("letterSpacing", spanMap, props_)->toDouble(); auto textShadowStr = GetKTValue("textShadow", spanMap, props_)->toString(); @@ -320,6 +323,14 @@ void KRParagraph::AddSpanToStyledString(const KRRenderValue::Map &spanMap, ArkUI OH_Drawing_SetTextStyleFontWeight(txtStyle, fontWeight); OH_Drawing_SetTextStyleBaseLine(txtStyle, TEXT_BASELINE_ALPHABETIC); OH_Drawing_SetTextStyleDecoration(txtStyle, textDecoration); + if (textDecoration != TEXT_DECORATION_NONE) { + if (textDecorationColorStr.length()) { + OH_Drawing_SetTextStyleDecorationColor(txtStyle, textDecorationColor); + } + if (textDecorationThickness > 0 && fontSize > 0) { + OH_Drawing_SetTextStyleDecorationThicknessScale(txtStyle, textDecorationThickness * dpi / fontSize); + } + } OH_Drawing_SetTextStyleFontStyle(txtStyle, fontStyle); if (letterSpacing > 0) { OH_Drawing_SetTextStyleLetterSpacing(txtStyle, letterSpacing * dpi); @@ -463,4 +474,4 @@ OH_Drawing_ShaderEffect *KRParagraph::CreateShaderEffect(std::shared_ptrtoFloat() / (fontSize / dpi); // 行间距比例 auto textAlign = kuikly::util::ConvertToTextAlign(GetKRValue("textAlign", spanMap, props_)->toString()); auto textDecoration = kuikly::util::ConvertToTextDecoration(GetKRValue("textDecoration", spanMap, props_)->toString()); + auto textDecorationColorStr = GetKRValue("textDecorationColor", spanMap, props_)->toString(); + auto textDecorationColor = textDecorationColorStr.length() ? kuikly::util::ConvertToHexColor(textDecorationColorStr) : color; + auto textDecorationThickness = GetKRValue("textDecorationThickness", spanMap, props_)->toFloat(); auto fontStyle = kuikly::util::ConvertToFontStyle(GetKRValue("fontStyle", spanMap, props_)->toString()); auto letterSpacing = GetKRValue("letterSpacing", spanMap, props_)->toDouble(); auto textShadowStr = GetKRValue("textShadow", spanMap, props_)->toString(); @@ -543,6 +546,14 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_SetTextStyleFontWeight(txtStyle, fontWeight); OH_Drawing_SetTextStyleBaseLine(txtStyle, TEXT_BASELINE_ALPHABETIC); OH_Drawing_SetTextStyleDecoration(txtStyle, textDecoration); + if (textDecoration != TEXT_DECORATION_NONE) { + if (textDecorationColorStr.length()) { + OH_Drawing_SetTextStyleDecorationColor(txtStyle, textDecorationColor); + } + if (textDecorationThickness > 0 && fontSize > 0) { + OH_Drawing_SetTextStyleDecorationThicknessScale(txtStyle, textDecorationThickness * dpi / fontSize); + } + } OH_Drawing_SetTextStyleFontStyle(txtStyle, fontStyle); if (letterSpacing > 0) { OH_Drawing_SetTextStyleLetterSpacing(txtStyle, letterSpacing * dpi); diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index 8d14c9b33..49cb42726 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -417,6 +417,21 @@ open class TextSpan : TextAttr(), ISpan { return this } + override fun textDecorationColor(color: Color): TextSpan { + TextConst.TEXT_DECORATION_COLOR with color.toString() + return this + } + + override fun textDecorationThickness(thickness: Float): TextSpan { + TextConst.TEXT_DECORATION_THICKNESS with thickness + return this + } + + override fun textDecorationOffset(offset: Float): TextSpan { + TextConst.TEXT_DECORATION_OFFSET with offset + return this + } + /** * 单击事件的定义 * @param handler 事件处理函数 diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index 6b19df70b..8a1544a17 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -390,6 +390,21 @@ open class TextAttr : Attr() { return this } + open fun textDecorationColor(color: Color): TextAttr { + TextConst.TEXT_DECORATION_COLOR with color.toString() + return this + } + + open fun textDecorationThickness(thickness: Float): TextAttr { + TextConst.TEXT_DECORATION_THICKNESS with thickness + return this + } + + open fun textDecorationOffset(offset: Float): TextAttr { + TextConst.TEXT_DECORATION_OFFSET with offset + return this + } + open fun textAlignCenter(): TextAttr { TextConst.TEXT_ALIGN with TextAlign.CENTER.value return this @@ -538,6 +553,9 @@ object TextConst { const val FONT_FAMILY = "fontFamily" const val TEXT_OVERFLOW = "lineBreakMode" const val TEXT_DECORATION = "textDecoration" + const val TEXT_DECORATION_COLOR = "textDecorationColor" + const val TEXT_DECORATION_THICKNESS = "textDecorationThickness" + const val TEXT_DECORATION_OFFSET = "textDecorationOffset" const val TEXT_COLOR = "color" const val TINT_COLOR = "tintColor" const val LINES = "numberOfLines" From 0e70080e12769bd32ffb801e91d9cad6bd586d88 Mon Sep 17 00:00:00 2001 From: artin Date: Sun, 5 Jul 2026 02:30:29 +0800 Subject: [PATCH 046/187] fix(richtext): restore markdown tag chrome support --- .../foundation/text/KuiklyTextExtension.kt | 12 ++ .../component/text/KRRichTextBuilder.kt | 64 +++++++++- .../component/text/KRRichTextViewDrawer.kt | 119 ++++++++++++++++++ .../tencent/kuikly/core/views/RichTextView.kt | 7 ++ .../com/tencent/kuikly/core/views/TextView.kt | 1 + 5 files changed, 202 insertions(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 676ea09e6..4e0109983 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -54,6 +54,7 @@ import com.tencent.kuikly.core.views.TextConst import com.tencent.kuikly.core.views.TextSpan private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "raft.build.markdown.inlineCode" +private const val SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG = "raft.build.markdown.tagChrome" // Returns platform-specific default font size private fun TextAttr.defaultFontSize(): Float { @@ -344,6 +345,12 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.start) positions.add(range.end) } + val slockMarkdownTagChromeAnnotations = + annoText.getStringAnnotations(SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG, 0, annoText.length) + slockMarkdownTagChromeAnnotations.forEach { range -> + positions.add(range.start) + positions.add(range.end) + } // Collect placeholder info and positions val (placeholders, _) = if (annoText.hasInlineContent()) { @@ -394,6 +401,11 @@ internal fun RichTextAttr.applyAnnotatedString( if (slockInlineCodeAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCode() } + slockMarkdownTagChromeAnnotations + .firstOrNull { range -> start >= range.start && end <= range.end } + ?.item + ?.takeIf { it.isNotBlank() } + ?.let { kind -> slockMarkdownTagChrome(kind) } // Apply ParagraphStyle annoText.paragraphStyles diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index a94006a72..11b954db0 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -115,6 +115,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps is TextSpanProps && spanProps.slockInlineCode) { spannedBuilder.applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) } + if (spanProps is TextSpanProps && spanProps.slockMarkdownTagChrome != null) { + spannedBuilder.applySlockMarkdownTagAtomicTextSpan(spanStart, spanEnd) + } } } if (textProps.richTextHeadIndent != 0) { @@ -203,7 +206,10 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { // 修饰相关 textSpans.add(ForegroundColorSpan(spanProps.color)) - if (spanProps.backgroundColor != Color.TRANSPARENT && !spanProps.slockInlineCode) { + if (spanProps.backgroundColor != Color.TRANSPARENT && + !spanProps.slockInlineCode && + spanProps.slockMarkdownTagChrome == null + ) { textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) } if (spanProps.textDecoration.isNotEmpty()) { @@ -231,6 +237,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.slockInlineCode) { textSpans.add(KRSlockInlineCodeSpan()) } + spanProps.slockMarkdownTagChrome?.let { kind -> + textSpans.add(KRSlockMarkdownTagSpan(kind)) + } spanProps.textShadow?.let { if (!it.isEmpty()) { @@ -284,6 +293,7 @@ class TextSpanProps( val backgroundImage: String val backgroundColor: Int val slockInlineCode: Boolean + val slockMarkdownTagChrome: String? var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -351,6 +361,9 @@ class TextSpanProps( ?: Color.TRANSPARENT slockInlineCode = spanValue.optInt(TextConst.SLOCK_INLINE_CODE, 0) == 1 || spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE, false) + slockMarkdownTagChrome = + spanValue.optString(TextConst.SLOCK_MARKDOWN_TAG_CHROME, "") + .takeIf { it.isNotEmpty() } val textShadowStr = spanValue.optString(KRTextProps.PROP_KEY_TEXT_SHADOW, "") textShadow = BoxShadow(textShadowStr, kuiklyContext) useDpFontSizeDim = spanValue.optInt(KRTextProps.PROP_KEY_TEXT_USE_DP_FONT_SIZE_DIM) == 1 @@ -385,6 +398,7 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { } class KRSlockInlineCodeSpan +class KRSlockMarkdownTagSpan(val kind: String) private class KRCustomUnderlineSpan( private val color: Int?, @@ -437,6 +451,54 @@ private class KRCustomUnderlineSpan( } } +private fun SpannableStringBuilder.applySlockMarkdownTagAtomicTextSpan(start: Int, end: Int) { + if (start < end) { + setSpan( + KRSlockMarkdownTagAtomicTextSpan(), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } +} + +private class KRSlockMarkdownTagAtomicTextSpan : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int = if (text == null || start >= end) { + 0 + } else { + val textWidth = paint.measureText(text, start, end) + val strokePadding = max(1f, paint.strokeWidth * 2f) + ceil((textWidth + strokePadding + edgePadding(paint) * 2f).toDouble()).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text != null && start < end) { + canvas.drawText(text, start, end, x + edgePadding(paint), y.toFloat(), paint) + } + } + + private fun edgePadding(paint: Paint): Float { + return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) + } +} + private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { var index = start var firstAtom = true diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 2d7b7f41c..9ec22413d 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -43,6 +43,15 @@ private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f private const val SLOCK_INLINE_CODE_BORDER_MIN_WIDTH = 2f +private const val SLOCK_MARKDOWN_TAG_KIND_CHANNEL = "channel" +private const val SLOCK_MARKDOWN_TAG_KIND_THREAD = "thread" +private const val SLOCK_MARKDOWN_TAG_KIND_TASK = "task" +private const val SLOCK_MARKDOWN_TAG_KIND_SELF_MENTION = "selfMention" +private const val SLOCK_MARKDOWN_TAG_KIND_ACTIVE = "active" +private const val SLOCK_MARKDOWN_TAG_CHANNEL_FILL_COLOR = 0x4DFE7DA8 +private const val SLOCK_MARKDOWN_TAG_THREAD_FILL_COLOR = 0x4D27CCF3 +private const val SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR = 0x66FFD440 +private const val SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR = 0xFFFFD440.toInt() /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -67,6 +76,15 @@ class KRRichTextViewDrawer(val textLayout: Layout) { isAntiAlias = false } private val slockInlineCodeRect = RectF() + private val slockMarkdownTagFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + private val slockMarkdownTagBorderPaint = Paint().apply { + style = Paint.Style.FILL + color = SLOCK_INLINE_CODE_BORDER_COLOR + isAntiAlias = false + } + private val slockMarkdownTagRect = RectF() private val wordIterator by lazy(LazyThreadSafetyMode.NONE) { WordIterator(textLayout.text, 0, textLayout.text.length, Locale.getDefault()) @@ -91,8 +109,87 @@ class KRRichTextViewDrawer(val textLayout: Layout) { */ fun draw(canvas: Canvas) { drawSlockInlineCodeChrome(canvas, drawFill = true, drawBorder = false) + drawSlockMarkdownTagChrome(canvas, drawFill = true, drawBorder = false) textLayout.draw(canvas) drawSlockInlineCodeChrome(canvas, drawFill = false, drawBorder = true) + drawSlockMarkdownTagChrome(canvas, drawFill = false, drawBorder = true) + } + + private fun drawSlockMarkdownTagChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { + val spanned = textLayout.text as? Spanned ?: return + val spans = spanned.getSpans(0, spanned.length, KRSlockMarkdownTagSpan::class.java) + if (spans.isEmpty()) return + + val paint = textLayout.paint + val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO + val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO + val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO + val fontMetrics = paint.fontMetrics + val layoutLeft = 0f + val layoutRight = textLayout.width.toFloat() + + spans.forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + if (start < 0 || end <= start) return@forEach + + if (drawFill) { + slockMarkdownTagFillPaint.color = span.kind.slockMarkdownTagFillColor() + } + val startLine = textLayout.getLineForOffset(start) + val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) + for (line in startLine..endLine) { + val lineStart = textLayout.getLineStart(line) + val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) + val segmentStart = max(start, lineStart) + val segmentEnd = min(end, lineVisibleEnd) + if (segmentEnd <= segmentStart) continue + + val startX = + if (segmentStart <= lineStart) { + layoutLeft + } else { + textLayout.getPrimaryHorizontal(segmentStart) + } + val endX = + if (segmentEnd >= lineVisibleEnd) { + textLayout.getLineRight(line) + } else { + textLayout.getPrimaryHorizontal(segmentEnd) + } + val segmentLeft = min(startX, endX) + val segmentRight = max(startX, endX) + val left = if (segmentStart == start) { + segmentLeft + horizontalMargin + } else { + segmentLeft - horizontalPadding + }.coerceAtLeast(layoutLeft) + val right = if (segmentEnd == end) { + segmentRight - horizontalMargin + } else { + segmentRight + horizontalPadding + }.coerceAtMost(layoutRight) + if (right <= left) continue + + val baseline = textLayout.getLineBaseline(line).toFloat() + val textTop = baseline + fontMetrics.ascent - verticalPadding + val textBottom = baseline + fontMetrics.descent + verticalPadding + val height = max(textBottom - textTop, minHeight) + val centerY = (textTop + textBottom) / 2f + val top = centerY - height / 2f + val bottom = centerY + height / 2f + if (bottom <= top) continue + + slockMarkdownTagRect.set(left, top, right, bottom) + if (drawFill) { + canvas.drawRect(slockMarkdownTagRect, slockMarkdownTagFillPaint) + } + if (drawBorder) { + canvas.drawSlockMarkdownTagBorder(left, top, right, bottom) + } + } + } } private fun drawSlockInlineCodeChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { @@ -180,6 +277,28 @@ class KRRichTextViewDrawer(val textLayout: Layout) { drawRect(borderRight - borderWidth, borderTop, borderRight, borderBottom, slockInlineCodeBorderPaint) } + private fun Canvas.drawSlockMarkdownTagBorder(left: Float, top: Float, right: Float, bottom: Float) { + val borderWidth = max(SLOCK_INLINE_CODE_BORDER_MIN_WIDTH, textLayout.paint.density * SLOCK_INLINE_CODE_BORDER_WIDTH_DP) + val borderLeft = floor(left) + val borderTop = floor(top) + val borderRight = ceil(right) + val borderBottom = ceil(bottom) + drawRect(borderLeft, borderTop, borderRight, borderTop + borderWidth, slockMarkdownTagBorderPaint) + drawRect(borderLeft, borderBottom - borderWidth, borderRight, borderBottom, slockMarkdownTagBorderPaint) + drawRect(borderLeft, borderTop, borderLeft + borderWidth, borderBottom, slockMarkdownTagBorderPaint) + drawRect(borderRight - borderWidth, borderTop, borderRight, borderBottom, slockMarkdownTagBorderPaint) + } + + private fun String.slockMarkdownTagFillColor(): Int = + when (this) { + SLOCK_MARKDOWN_TAG_KIND_CHANNEL -> SLOCK_MARKDOWN_TAG_CHANNEL_FILL_COLOR + SLOCK_MARKDOWN_TAG_KIND_THREAD -> SLOCK_MARKDOWN_TAG_THREAD_FILL_COLOR + SLOCK_MARKDOWN_TAG_KIND_SELF_MENTION -> SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR + SLOCK_MARKDOWN_TAG_KIND_ACTIVE -> SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR + SLOCK_MARKDOWN_TAG_KIND_TASK -> SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR + else -> SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR + } + private fun Layout.slockInlineCodeVisibleEnd(line: Int): Int { val lineStart = getLineStart(line) val ellipsisCount = getEllipsisCount(line) diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index 49cb42726..c43b68e06 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -417,6 +417,13 @@ open class TextSpan : TextAttr(), ISpan { return this } + fun slockMarkdownTagChrome(kind: String): TextSpan { + if (kind.isNotBlank()) { + setProp(TextConst.SLOCK_MARKDOWN_TAG_CHROME, kind) + } + return this + } + override fun textDecorationColor(color: Color): TextSpan { TextConst.TEXT_DECORATION_COLOR with color.toString() return this diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index 8a1544a17..71eb52f0f 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -572,6 +572,7 @@ object TextConst { const val TEXT_POST_PROCESSOR = "textPostProcessor" const val TEXT_USE_DP_FONT_SIZE_DIM = "useDpFontSizeDim" const val SLOCK_INLINE_CODE = "slockInlineCode" + const val SLOCK_MARKDOWN_TAG_CHROME = "slockMarkdownTagChrome" const val SHADOW_METHOD_IS_LINE_BREAK_MARGIN = "isLineBreakMargin" const val PLACEHOLDER = "placeholder" From dd76e6548e58bdc0957bd19b572ef25c0da560f1 Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Sat, 4 Jul 2026 18:23:41 +0000 Subject: [PATCH 047/187] feat(android-render): sharp small-angle static rotation via canvas matrix View.rotation is a RenderNode property transform: under hardware rendering, text glyphs are rasterized axis-aligned into the font atlas and then GPU-resampled, which visibly blurs small text and leaves rotated quad edges unantialiased (Slock web hit the same issue, see .tilt-neg-2 in index.css). Canvas-level matrices participate in glyph rasterization at display-list record time (same path skew already uses), keeping text and edges sharp. Route static, pure-Z, small-angle (<=15deg) rotations through the view decorator canvas matrix instead of View.rotation. Animation frames (TransformTypeEvaluator) keep the property path for per-frame perf; the canvas angle is stashed in viewData so animations starting from a canvas-rotated state pick up the correct start angle. Slock task #338. Co-Authored-By: Claude Fable 5 --- .../css/animation/AnimationTypeEvaluator.kt | 2 +- .../android/css/animation/KRCSSAnimation.kt | 81 +++++++++++++++---- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/AnimationTypeEvaluator.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/AnimationTypeEvaluator.kt index def95fa6c..5ccaa7ed1 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/AnimationTypeEvaluator.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/AnimationTypeEvaluator.kt @@ -61,7 +61,7 @@ internal class BackgroundColorTypeEvaluator(private val targetView: View) : Type */ internal class TransformTypeEvaluator(private val targetView: View) : TypeEvaluator { - private val reuseTransform = KRCSSTransform(null, targetView) + private val reuseTransform = KRCSSTransform(null, targetView).apply { fromAnimation = true } override fun evaluate( fraction: Float, diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/KRCSSAnimation.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/KRCSSAnimation.kt index 8d617ce5e..90971e01b 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/KRCSSAnimation.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/animation/KRCSSAnimation.kt @@ -23,6 +23,9 @@ import com.tencent.kuikly.core.render.android.KuiklyRenderView import com.tencent.kuikly.core.render.android.const.KRCssConst import com.tencent.kuikly.core.render.android.css.ktx.frameHeight import com.tencent.kuikly.core.render.android.css.ktx.frameWidth +import com.tencent.kuikly.core.render.android.css.ktx.getViewData +import com.tencent.kuikly.core.render.android.css.ktx.putViewData +import com.tencent.kuikly.core.render.android.css.ktx.removeViewData import com.tencent.kuikly.core.render.android.css.ktx.removeHRAnimation import com.tencent.kuikly.core.render.android.css.ktx.toPxF import com.tencent.kuikly.core.render.android.css.ktx.obtainViewDecorator @@ -319,6 +322,12 @@ class KRCSSTransform(transform: String?, private val target: View) { var skewX: Float = DEFAULT_SKEW_X var skewY: Float = DEFAULT_SKEW_Y + /** + * 是否来自动画帧驱动([TransformTypeEvaluator])。动画帧必须走 View 属性 + * 变换(RenderNode 硬件加速),不参与 canvas 矩阵旋转路径。 + */ + internal var fromAnimation = false + init { initTransform(transform) } @@ -327,7 +336,8 @@ class KRCSSTransform(transform: String?, private val target: View) { * 应用transform到targetView */ fun applyTransform() { - target.rotation = rotate + val canvasRotation = shouldRenderRotationOnCanvas() + target.rotation = if (canvasRotation) DEFAULT_ROTATE else rotate target.rotationX = rotateX target.rotationY = rotateY target.scaleX = scaleX @@ -346,7 +356,7 @@ class KRCSSTransform(transform: String?, private val target: View) { // For more information, see https://github.com/facebook/react-native/pull/18302 target.cameraDistance = density * density * DEFAULT_PERSPECTIVE * sqrt(5f) } - applySkewTransform() + applyCanvasMatrixTransform(canvasRotation) handleOverflowBounds() } @@ -399,7 +409,7 @@ class KRCSSTransform(transform: String?, private val target: View) { } private fun initTransformFromTargetView() { - rotate = target.rotation + rotate = target.getViewData(KEY_CANVAS_ROTATION) ?: target.rotation rotateX = target.rotationX rotateY = target.rotationY scaleX = target.scaleX @@ -454,29 +464,68 @@ class KRCSSTransform(transform: String?, private val target: View) { } - private fun applySkewTransform() { - if (skewX == DEFAULT_SKEW_X && skewY == DEFAULT_SKEW_Y) { + /** + * 小角度静态旋转改走 canvas 矩阵而不是 View.rotation。 + * + * View.rotation 是 RenderNode 属性变换:硬件渲染下文字 glyph 先按未旋转 + * 方向栅格化进字体图集,再整体被 GPU 重采样,小字号文字明显发糊,旋转 + * 四边形边缘也缺少抗锯齿(web 端同款问题见 slock index.css .tilt-neg-2)。 + * canvas 层矩阵在录制 display list 时参与 glyph 栅格化(与 skew 走的 + * [KRViewDecoration.matrix] 同一条路径),文字与边缘保持锐利。 + * + * 仅在满足以下条件时启用,避免影响动画性能与触摸命中: + * 1. 非动画帧驱动(动画期间保持属性变换,保证逐帧性能); + * 2. 纯 Z 轴旋转且无缩放/平移(canvas 矩阵不改 View 触摸映射,其他 + * 分量混合时坐标语义复杂); + * 3. 角度 ≤ [CANVAS_ROTATION_MAX_DEGREES](装饰性小倾斜的触摸命中 + * 误差只有 1~2px,可以忽略;大角度旋转的交互元素仍需属性变换)。 + */ + private fun shouldRenderRotationOnCanvas(): Boolean { + return !fromAnimation && + rotate != DEFAULT_ROTATE && + abs(rotate) <= CANVAS_ROTATION_MAX_DEGREES && + rotateX == DEFAULT_ROTATE_X && rotateY == DEFAULT_ROTATE_Y && + scaleX == DEFAULT_SCALE_X && scaleY == DEFAULT_SCALE_Y && + translateX == DEFAULT_TRANSLATE_X && translateY == DEFAULT_TRANSLATE_Y + } + + private fun applyCanvasMatrixTransform(canvasRotation: Boolean) { + val hasSkewValue = skewX != DEFAULT_SKEW_X || skewY != DEFAULT_SKEW_Y + if (!hasSkewValue && !canvasRotation) { target.optViewDecorator()?.matrix = null - } else { + target.removeViewData(KEY_CANVAS_ROTATION) + return + } + val matrix = Matrix() + if (hasSkewValue) { val horizontalSkewAngleInRadians = Math.toRadians(skewX.toDouble()) val verticalSkewAngleInRadians = Math.toRadians(skewY.toDouble()) - target.obtainViewDecorator().matrix = Matrix().apply { - setSkew( - tan(horizontalSkewAngleInRadians).toFloat(), - tan(verticalSkewAngleInRadians).toFloat(), - pivotX, - pivotY - ) - } + matrix.setSkew( + tan(horizontalSkewAngleInRadians).toFloat(), + tan(verticalSkewAngleInRadians).toFloat(), + pivotX, + pivotY + ) + } + if (canvasRotation) { + matrix.postRotate(rotate, pivotX, pivotY) + // 记录 canvas 旋转角,供 initTransformFromTargetView 读取—— + // 否则以当前状态为起点的动画会误把起始角当成 0。 + target.putViewData(KEY_CANVAS_ROTATION, rotate) + } else { + target.removeViewData(KEY_CANVAS_ROTATION) } + target.obtainViewDecorator().matrix = matrix + target.invalidate() } private fun resetSkewTransform() { if (skewX != DEFAULT_SKEW_X || skewY != DEFAULT_SKEW_Y) { skewX = DEFAULT_SKEW_X skewY = DEFAULT_SKEW_Y - target.optViewDecorator()?.matrix = null } + // 无条件清掉 canvas 矩阵:skew 或 canvas 旋转任一设置过都需要复位 + target.optViewDecorator()?.matrix = null } private fun handleOverflowBounds() { @@ -540,5 +589,7 @@ class KRCSSTransform(transform: String?, private val target: View) { private const val DEFAULT_SKEW_X = 0f private const val DEFAULT_SKEW_Y = 0f private const val DEFAULT_PERSPECTIVE = 1280f + private const val CANVAS_ROTATION_MAX_DEGREES = 15f + private const val KEY_CANVAS_ROTATION = "kr_canvas_rotation" } } From 7ea6b09c883d38b37a6418d30ad9c537205c036c Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Sun, 5 Jul 2026 07:44:25 +0000 Subject: [PATCH 048/187] fix(android-render): stable line centering for editable text fields HRLineHeightSpan centers the line around the ink bounds of the measured text (glyphVisualCenter). For an editable field that makes the line's vertical position depend on which glyphs are typed: 'as' sits centered on x-height ink, appending an ascender glyph ('f') re-centers the whole line and the text visibly jumps (Slock task #355). Add centerOnGlyphBounds (default true, preserving rich-text behavior) and construct the span with false in KRTextFieldView so editable fields always center on the font-metrics midpoint. Center selection extracted into resolveLineCenter for unit coverage. Co-Authored-By: Claude Fable 5 Signed-off-by: CC-Wow2 --- .../expand/component/KRTextFieldView.kt | 3 +- .../component/text/KRRichTextBuilder.kt | 30 ++++++++++++++++--- .../text/HRLineHeightSpanGlyphTest.kt | 22 ++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt index d7ef054cd..c719d3cb6 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt @@ -1010,7 +1010,8 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : } text.removeSpan(it) } - lineHeightSpan = HRLineHeightSpan(pxLineHeight) + // Stable font-metrics centering: typing must never shift the line (task #355). + lineHeightSpan = HRLineHeightSpan(pxLineHeight, centerOnGlyphBounds = false) ensureLineHeightSpan(text) observeTextWatcher() return true diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 11b954db0..e7d8365cc 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -745,7 +745,17 @@ class FontFamilySpan(fontFamily: String, typeFaceLoader: TypeFaceLoader?) : Type } } -class HRLineHeightSpan(internal val height: Int) : LineHeightSpan, LineHeightSpan.WithDensity { +class HRLineHeightSpan( + internal val height: Int, + /** + * When false, skip [glyphVisualCenter] and always center on the font + * metrics midpoint. Editable fields MUST use false: ink-bounds centering + * makes the line's vertical position depend on which glyphs are typed + * ("as" sits x-height-centered, appending an ascender like "f" re-centers + * the whole line and the text visibly jumps — Slock task #355). + */ + internal val centerOnGlyphBounds: Boolean = true +) : LineHeightSpan, LineHeightSpan.WithDensity { internal companion object { fun applyCenteredLineHeight(height: Int, fm: Paint.FontMetricsInt, center: Int) { @@ -776,15 +786,27 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan, LineHeightSpa fm: Paint.FontMetricsInt, paint: TextPaint ) { - val visualCenter = glyphVisualCenter(text, start, end, paint) - ?: ((fm.ascent + fm.descent) / 2) - applyCenteredLineHeight(fm, visualCenter) + applyCenteredLineHeight(fm, resolveLineCenter(text, start, end, paint, fm)) } private fun applyCenteredLineHeight(fm: Paint.FontMetricsInt, center: Int) { applyCenteredLineHeight(height, fm, center) } + internal fun resolveLineCenter( + text: CharSequence?, + start: Int, + end: Int, + paint: TextPaint?, + fm: Paint.FontMetricsInt + ): Int { + val metricsCenter = (fm.ascent + fm.descent) / 2 + if (!centerOnGlyphBounds || paint == null) { + return metricsCenter + } + return glyphVisualCenter(text, start, end, paint) ?: metricsCenter + } + private fun glyphVisualCenter( text: CharSequence?, start: Int, diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt index c074328f0..f3dcc7246 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt @@ -64,4 +64,26 @@ class HRLineHeightSpanGlyphTest { assertEquals(5, metrics.descent) assertEquals(17, metrics.bottom - metrics.top) } + + @Test + fun stableCenteringIgnoresGlyphBoundsSoTypingNeverShiftsTheLine() { + // Editable fields center on font metrics only (task #355): the chosen + // center must be identical no matter what text is measured, so + // appending an ascender glyph ("as" -> "asf") cannot re-center the + // line. paint=null exercises the same guard the stable flag uses. + val span = HRLineHeightSpan(20, centerOnGlyphBounds = false) + val metrics = Paint.FontMetricsInt().apply { + top = -12; ascent = -12; descent = 4; bottom = 4 + } + + val centerShort = span.resolveLineCenter("as", 0, 2, null, metrics) + val centerTall = span.resolveLineCenter("asf", 0, 3, null, metrics) + + assertEquals((-12 + 4) / 2, centerShort) + assertEquals(centerShort, centerTall) + + HRLineHeightSpan.applyCenteredLineHeight(20, metrics, centerTall) + assertEquals(20, metrics.bottom - metrics.top) + } + } From 03ef5cf1a99d6b0de62a7cf027ebe8701bfa74d7 Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Sun, 5 Jul 2026 07:56:50 +0000 Subject: [PATCH 049/187] revert(android): content-independent line height centering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the ascent/descent re-basing (7b9503d) and ink-bounds centering (b992014) from HRLineHeightSpan — artin's audit call in Slock task #355: neither actually fixed its target, and ink-bounds centering made line placement depend on which glyphs are present (composer jumped while typing; static rows with different strings sat on different baselines, unlike React's CSS line-height). Back to 0989f41 semantics: even top/bottom split of the extra leading, ascent/descent collapsed to the line extents. Supersedes 7ea6b09's editable-field-only exemption — stable centering is now universal. Tests updated to lock content-independence and exact odd-height distribution. Co-Authored-By: Claude Fable 5 Signed-off-by: CC-Wow2 --- .../expand/component/KRTextFieldView.kt | 3 +- .../component/text/KRRichTextBuilder.kt | 85 ++++--------------- .../text/HRLineHeightSpanGlyphTest.kt | 75 +++++----------- .../component/text/HRLineHeightSpanTest.java | 13 +-- 4 files changed, 47 insertions(+), 129 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt index c719d3cb6..d7ef054cd 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt @@ -1010,8 +1010,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : } text.removeSpan(it) } - // Stable font-metrics centering: typing must never shift the line (task #355). - lineHeightSpan = HRLineHeightSpan(pxLineHeight, centerOnGlyphBounds = false) + lineHeightSpan = HRLineHeightSpan(pxLineHeight) ensureLineHeightSpan(text) observeTextWatcher() return true diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index e7d8365cc..fcd87028f 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -19,7 +19,6 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Paint -import android.graphics.Rect import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface @@ -745,27 +744,16 @@ class FontFamilySpan(fontFamily: String, typeFaceLoader: TypeFaceLoader?) : Type } } -class HRLineHeightSpan( - internal val height: Int, - /** - * When false, skip [glyphVisualCenter] and always center on the font - * metrics midpoint. Editable fields MUST use false: ink-bounds centering - * makes the line's vertical position depend on which glyphs are typed - * ("as" sits x-height-centered, appending an ascender like "f" re-centers - * the whole line and the text visibly jumps — Slock task #355). - */ - internal val centerOnGlyphBounds: Boolean = true -) : LineHeightSpan, LineHeightSpan.WithDensity { - - internal companion object { - fun applyCenteredLineHeight(height: Int, fm: Paint.FontMetricsInt, center: Int) { - fm.ascent = center - height / 2 - fm.descent = fm.ascent + height - fm.top = fm.ascent - fm.bottom = fm.descent - } - } +class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { + // History (Slock task #355 audit): 7b9503d re-based the distribution on + // ascent/descent and b992014 centered on the measured text's ink bounds + // (LineHeightSpan.WithDensity + getTextBounds). Ink-bounds centering made + // the line's vertical position depend on WHICH glyphs are present — the + // composer jumped while typing and static rows with different strings sat + // on different baselines (React's CSS line-height never does this). Both + // are reverted to the content-independent additive centering below + // (0989f41 semantics: even top/bottom split of the extra leading). override fun chooseHeight( text: CharSequence?, start: Int, @@ -774,55 +762,12 @@ class HRLineHeightSpan( lineHeight: Int, fm: Paint.FontMetricsInt ) { - applyCenteredLineHeight(fm, (fm.ascent + fm.descent) / 2) - } - - override fun chooseHeight( - text: CharSequence, - start: Int, - end: Int, - spanstartv: Int, - lineHeight: Int, - fm: Paint.FontMetricsInt, - paint: TextPaint - ) { - applyCenteredLineHeight(fm, resolveLineCenter(text, start, end, paint, fm)) - } - - private fun applyCenteredLineHeight(fm: Paint.FontMetricsInt, center: Int) { - applyCenteredLineHeight(height, fm, center) - } - - internal fun resolveLineCenter( - text: CharSequence?, - start: Int, - end: Int, - paint: TextPaint?, - fm: Paint.FontMetricsInt - ): Int { - val metricsCenter = (fm.ascent + fm.descent) / 2 - if (!centerOnGlyphBounds || paint == null) { - return metricsCenter - } - return glyphVisualCenter(text, start, end, paint) ?: metricsCenter - } - - private fun glyphVisualCenter( - text: CharSequence?, - start: Int, - end: Int, - paint: TextPaint - ): Int? { - if (text == null || start >= end) { - return null - } - val bounds = Rect() - paint.getTextBounds(text, start, end, bounds) - return if (bounds.isEmpty) { - null - } else { - (bounds.top + bounds.bottom) / 2 - } + val additional: Int = height - (-fm.top + fm.bottom) + val topExtra = additional / 2 + fm.top -= topExtra + fm.bottom += additional - topExtra + fm.ascent = fm.top + fm.descent = fm.bottom } } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt index f3dcc7246..74f867bef 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt @@ -21,69 +21,40 @@ import org.junit.Test class HRLineHeightSpanGlyphTest { + // Line placement must be content-independent (Slock task #355): the same + // font metrics must resolve to the same line box no matter which string + // is measured, so typing an ascender glyph ("as" -> "asf") can never + // re-center the line. Ink-bounds centering (b992014) is reverted. @Test - fun lineHeightCanCenterAroundActualGlyphBounds() { - val metrics = Paint.FontMetricsInt().apply { - top = -12 - ascent = -12 - descent = 4 - bottom = 4 + fun sameMetricsResolveToSameLineBoxRegardlessOfText() { + val span = HRLineHeightSpan(20) + fun metrics() = Paint.FontMetricsInt().apply { + top = -12; ascent = -12; descent = 4; bottom = 4 } - HRLineHeightSpan.applyCenteredLineHeight( - height = 20, - fm = metrics, - center = -5 - ) + val short = metrics() + val tall = metrics() + span.chooseHeight("as", 0, 2, 0, 20, short) + span.chooseHeight("asf", 0, 3, 0, 20, tall) - assertEquals(-15, metrics.top) - assertEquals(-15, metrics.ascent) - assertEquals(5, metrics.bottom) - assertEquals(5, metrics.descent) - assertEquals(20, metrics.bottom - metrics.top) + assertEquals(short.top, tall.top) + assertEquals(short.ascent, tall.ascent) + assertEquals(short.descent, tall.descent) + assertEquals(short.bottom, tall.bottom) + assertEquals(20, short.bottom - short.top) } @Test - fun centeredLineHeightKeepsExactOddHeight() { + fun exactLineHeightIsKeptForOddHeights() { + val span = HRLineHeightSpan(17) val metrics = Paint.FontMetricsInt().apply { - top = -10 - ascent = -10 - descent = 3 - bottom = 3 + top = -10; ascent = -10; descent = 3; bottom = 3 } - HRLineHeightSpan.applyCenteredLineHeight( - height = 17, - fm = metrics, - center = -4 - ) + span.chooseHeight("x", 0, 1, 0, 17, metrics) - assertEquals(-12, metrics.top) - assertEquals(-12, metrics.ascent) - assertEquals(5, metrics.bottom) - assertEquals(5, metrics.descent) assertEquals(17, metrics.bottom - metrics.top) + assertEquals(metrics.top, metrics.ascent) + assertEquals(metrics.bottom, metrics.descent) } - - @Test - fun stableCenteringIgnoresGlyphBoundsSoTypingNeverShiftsTheLine() { - // Editable fields center on font metrics only (task #355): the chosen - // center must be identical no matter what text is measured, so - // appending an ascender glyph ("as" -> "asf") cannot re-center the - // line. paint=null exercises the same guard the stable flag uses. - val span = HRLineHeightSpan(20, centerOnGlyphBounds = false) - val metrics = Paint.FontMetricsInt().apply { - top = -12; ascent = -12; descent = 4; bottom = 4 - } - - val centerShort = span.resolveLineCenter("as", 0, 2, null, metrics) - val centerTall = span.resolveLineCenter("asf", 0, 3, null, metrics) - - assertEquals((-12 + 4) / 2, centerShort) - assertEquals(centerShort, centerTall) - - HRLineHeightSpan.applyCenteredLineHeight(20, metrics, centerTall) - assertEquals(20, metrics.bottom - metrics.top) - } - } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java index cb69d6fb4..066daf11b 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java @@ -40,7 +40,10 @@ public void oddLineHeightLeadingDoesNotPushBaselineDown() { } @Test - public void lineHeightCentersAroundGlyphMetricsWhenFontPaddingDiffers() { + public void lineHeightDistributesFromLineExtentsWhenFontPaddingDiffers() { + // Reverted to top/bottom-based distribution (Slock task #355 audit): + // ascent/descent re-basing (7b9503d) and ink-bounds centering + // (b992014) both made line placement inconsistent across strings. Paint.FontMetricsInt metrics = new Paint.FontMetricsInt(); metrics.top = -18; metrics.ascent = -13; @@ -49,10 +52,10 @@ public void lineHeightCentersAroundGlyphMetricsWhenFontPaddingDiffers() { new HRLineHeightSpan(22).chooseHeight("", 0, 0, 0, 0, metrics); - assertEquals(-15, metrics.top); - assertEquals(-15, metrics.ascent); - assertEquals(7, metrics.bottom); - assertEquals(7, metrics.descent); + assertEquals(-17, metrics.top); + assertEquals(-17, metrics.ascent); + assertEquals(5, metrics.bottom); + assertEquals(5, metrics.descent); assertEquals(22, metrics.bottom - metrics.top); } } From f980261fbac2a3235714386a0bb6780a5fa09465 Mon Sep 17 00:00:00 2001 From: Codex-Kuikly-KMP Date: Sun, 5 Jul 2026 17:56:24 +0800 Subject: [PATCH 050/187] fix(compose): dispatch key input events Signed-off-by: Codex-Kuikly-KMP --- .../compose/ui/input/key/KeyEvent.android.kt | 37 ++++ .../kuikly/compose/ComposeContainer.kt | 55 ++++++ .../kuikly/compose/ComposeSceneMediator.kt | 4 + .../kuikly/compose/ui/focus/FocusOwner.kt | 30 ++-- .../kuikly/compose/ui/focus/FocusOwnerImpl.kt | 120 +++++++------ .../kuikly/compose/ui/input/key/KeyEvent.kt | 158 ++++++------------ .../compose/ui/input/key/KeyInputModifier.kt | 75 +++++---- .../kuikly/compose/ui/node/NodeKind.kt | 11 +- .../compose/ui/scene/BaseComposeScene.kt | 7 + .../kuikly/compose/ui/scene/ComposeScene.kt | 9 + .../compose/ui/scene/KuiklyComposeScene.kt | 5 +- .../compose/ui/input/key/KeyEvent.native.kt | 65 +++++++ .../core/render/android/KuiklyRenderView.kt | 33 +++- .../expand/KuiklyRenderViewBaseDelegator.kt | 13 +- .../KuiklyRenderViewControllerBaseDelegator.h | 14 +- .../KuiklyRenderViewControllerBaseDelegator.m | 20 ++- core-render-ios/View/KuiklyRenderView.h | 14 +- core-render-ios/View/KuiklyRenderView.m | 21 ++- .../src/main/ets/IKuiklyRenderView.ets | 15 ++ .../src/main/ets/KRNativeRenderController.ets | 15 +- 20 files changed, 491 insertions(+), 230 deletions(-) create mode 100644 compose/src/androidMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.android.kt create mode 100644 compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt diff --git a/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.android.kt b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.android.kt new file mode 100644 index 000000000..fc0d32ef2 --- /dev/null +++ b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.android.kt @@ -0,0 +1,37 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.input.key + +import android.view.KeyEvent as AndroidKeyEvent + +/** + * Convert an Android hardware key event into the Kuikly Compose key event model. + */ +fun AndroidKeyEvent.toComposeKeyEvent(): KeyEvent = + KeyEvent( + key = Key(keyCode.toLong()), + type = when (action) { + AndroidKeyEvent.ACTION_UP -> KeyEventType.KeyUp + AndroidKeyEvent.ACTION_DOWN -> KeyEventType.KeyDown + else -> KeyEventType.Unknown + }, + utf16CodePoint = unicodeChar, + isAltPressed = isAltPressed, + isCtrlPressed = isCtrlPressed, + isMetaPressed = isMetaPressed, + isShiftPressed = isShiftPressed, + nativeKeyEvent = this, + ) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt index 0464e5afb..13cf0c978 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt @@ -35,6 +35,9 @@ import com.tencent.kuikly.core.module.FileModule import com.tencent.kuikly.core.module.Module import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.InternalComposeUiApi +import com.tencent.kuikly.compose.ui.input.key.Key +import com.tencent.kuikly.compose.ui.input.key.KeyEvent +import com.tencent.kuikly.compose.ui.input.key.KeyEventType import com.tencent.kuikly.compose.ui.platform.WindowInfoImpl import com.tencent.kuikly.compose.ui.scene.ComposeScene import com.tencent.kuikly.compose.ui.scene.KuiklyComposeScene @@ -80,6 +83,22 @@ open class ComposeContainer : * 建议在ComposeContainer.willInit方法内使用,在setContent之前设置 */ var enableConsumeSnapshot: Boolean = true + + /** + * Pager event name that render hosts can use to send hardware key events to Compose. + */ + const val PAGER_EVENT_KEY_EVENT = "keyEvent" + + const val KEY_EVENT_KEY_CODE = "keyCode" + const val KEY_EVENT_TYPE = "type" + const val KEY_EVENT_TYPE_UNKNOWN = 0 + const val KEY_EVENT_TYPE_UP = 1 + const val KEY_EVENT_TYPE_DOWN = 2 + const val KEY_EVENT_UTF16_CODE_POINT = "utf16CodePoint" + const val KEY_EVENT_ALT_PRESSED = "altPressed" + const val KEY_EVENT_CTRL_PRESSED = "ctrlPressed" + const val KEY_EVENT_META_PRESSED = "metaPressed" + const val KEY_EVENT_SHIFT_PRESSED = "shiftPressed" } override var ignoreLayout = true @@ -261,6 +280,15 @@ open class ComposeContainer : return mediator } + /** + * Dispatch a hardware key event into the Compose focus tree. + * + * Platform render hosts can normalize their native key event into [KeyEvent] and call this + * method to drive [Modifier.onPreviewKeyEvent] and [Modifier.onKeyEvent] handlers. + */ + fun sendKeyEvent(keyEvent: KeyEvent): Boolean = + mediator?.sendKeyEvent(keyEvent) ?: false + override fun onReceivePagerEvent(pagerEvent: String, eventData: JSONObject) { super.onReceivePagerEvent(pagerEvent, eventData) if (pagerEvent == PAGER_EVENT_ROOT_VIEW_SIZE_CHANGED) { @@ -283,9 +311,35 @@ open class ComposeContainer : val fontWeightScale = eventData.optDouble("fontWeightScale", 1.0) val fontSizeScale = eventData.optDouble("fontSizeScale", 1.0) configuration?.onFontConfigChange(fontSizeScale, fontWeightScale) + } else if (pagerEvent == PAGER_EVENT_KEY_EVENT) { + sendKeyEvent(eventData.toKeyEvent()) } } + private fun JSONObject.toKeyEvent(): KeyEvent = + KeyEvent( + key = Key(optLong(KEY_EVENT_KEY_CODE, Key.Unknown.keyCode)), + type = optKeyEventType(), + utf16CodePoint = optInt(KEY_EVENT_UTF16_CODE_POINT, 0), + isAltPressed = optBoolean(KEY_EVENT_ALT_PRESSED, false), + isCtrlPressed = optBoolean(KEY_EVENT_CTRL_PRESSED, false), + isMetaPressed = optBoolean(KEY_EVENT_META_PRESSED, false), + isShiftPressed = optBoolean(KEY_EVENT_SHIFT_PRESSED, false), + ) + + private fun JSONObject.optKeyEventType(): KeyEventType = + when (optInt(KEY_EVENT_TYPE, KEY_EVENT_TYPE_UNKNOWN)) { + KEY_EVENT_TYPE_UP -> KeyEventType.KeyUp + KEY_EVENT_TYPE_DOWN -> KeyEventType.KeyDown + else -> { + when (optString(KEY_EVENT_TYPE, "")) { + "KeyUp", "keyUp", "up" -> KeyEventType.KeyUp + "KeyDown", "keyDown", "down" -> KeyEventType.KeyDown + else -> KeyEventType.Unknown + } + } + } + private fun updateWindowContainer(frame: Frame) { windowInfo.containerSize = IntSize( (frame.width * pagerDensity()).fastRoundToInt(), @@ -356,4 +410,5 @@ open class ComposeContainer : override fun createExternalModules(): Map? { return null } + } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeSceneMediator.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeSceneMediator.kt index ba1bf86ff..c37dc12ee 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeSceneMediator.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeSceneMediator.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.InternalComposeUiApi +import com.tencent.kuikly.compose.ui.input.key.KeyEvent import com.tencent.kuikly.compose.ui.platform.LocalConfiguration import com.tencent.kuikly.compose.ui.platform.WindowInfo import com.tencent.kuikly.compose.ui.scene.ComposeScene @@ -114,6 +115,9 @@ class ComposeSceneMediator( } } + fun sendKeyEvent(keyEvent: KeyEvent): Boolean = + scene.sendKeyEvent(keyEvent) + fun updateDensity(toFloat: Float) { scene.density = Density(toFloat) } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwner.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwner.kt index d0ccc2724..c6aec86c1 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwner.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwner.kt @@ -18,7 +18,7 @@ package com.tencent.kuikly.compose.ui.focus import com.tencent.kuikly.compose.ui.Modifier import com.tencent.kuikly.compose.ui.geometry.Rect -//import com.tencent.kuikly.compose.ui.input.key.KeyEvent +import com.tencent.kuikly.compose.ui.input.key.KeyEvent //import com.tencent.kuikly.compose.ui.input.rotary.RotaryScrollEvent /** @@ -126,20 +126,20 @@ internal interface FocusOwner : FocusManager { */ fun getFocusRect(): Rect? -// /** -// * Dispatches a key event through the compose hierarchy. -// * -// * When an embedded subview has focus, we call onPreviewKeyEvents for all the parents, and then -// * invoke onFocusedItem before we call onKeyEvent on all the parents. -// * -// * @param keyEvent the key event to be dispatched -// * -// * @param onFocusedItem the block that is run after calling onPreviewKeyEvents on all the -// * parents. Returning true will consume the event and prevent the event from propagating -// * to the onKeyEvent modifiers on parents. This is used to dispatch key events to embedded -// * sub-views. -// */ -// fun dispatchKeyEvent(keyEvent: KeyEvent, onFocusedItem: () -> Boolean = { false }): Boolean + /** + * Dispatches a key event through the compose hierarchy. + * + * When an embedded subview has focus, we call onPreviewKeyEvents for all the parents, and then + * invoke onFocusedItem before we call onKeyEvent on all the parents. + * + * @param keyEvent the key event to be dispatched + * + * @param onFocusedItem the block that is run after calling onPreviewKeyEvents on all the + * parents. Returning true will consume the event and prevent the event from propagating + * to the onKeyEvent modifiers on parents. This is used to dispatch key events to embedded + * sub-views. + */ + fun dispatchKeyEvent(keyEvent: KeyEvent, onFocusedItem: () -> Boolean = { false }): Boolean // // /** // * Dispatches an intercepted soft keyboard key event through the compose hierarchy. diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt index e159140d5..5b0b4fbe9 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt @@ -29,11 +29,9 @@ import com.tencent.kuikly.compose.ui.focus.FocusDirection.Companion.Previous import com.tencent.kuikly.compose.ui.focus.FocusRequester.Companion.Cancel import com.tencent.kuikly.compose.ui.focus.FocusRequester.Companion.Default import com.tencent.kuikly.compose.ui.geometry.Rect -//import com.tencent.kuikly.compose.ui.input.key.KeyEvent -//import com.tencent.kuikly.compose.ui.input.key.KeyEventType.Companion.KeyDown -//import com.tencent.kuikly.compose.ui.input.key.KeyEventType.Companion.KeyUp -//import com.tencent.kuikly.compose.ui.input.key.key -//import com.tencent.kuikly.compose.ui.input.key.type +import com.tencent.kuikly.compose.ui.input.key.KeyEvent +import com.tencent.kuikly.compose.ui.input.key.KeyEventType.Companion.KeyDown +import com.tencent.kuikly.compose.ui.input.key.KeyEventType.Companion.KeyUp //import com.tencent.kuikly.compose.ui.input.rotary.RotaryScrollEvent import com.tencent.kuikly.compose.ui.node.DelegatableNode import com.tencent.kuikly.compose.ui.node.ModifierNodeElement @@ -255,30 +253,30 @@ internal class FocusOwnerImpl( } } -// /** -// * Dispatches a key event through the compose hierarchy. -// */ -// override fun dispatchKeyEvent(keyEvent: KeyEvent, onFocusedItem: () -> Boolean): Boolean { -// if (focusInvalidationManager.hasPendingInvalidation()) { -// // Ignoring this to unblock b/346370327. -// println("$Warning: Dispatching key event while focus system is invalidated.") -// return false -// } -// if (!validateKeyEvent(keyEvent)) return false -// -// val activeFocusTarget = rootFocusNode.findActiveFocusNode() -// val focusedKeyInputNode = activeFocusTarget?.lastLocalKeyInputNode() -// ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node -// ?: rootFocusNode.nearestAncestor(Nodes.KeyInput)?.node -// -// focusedKeyInputNode?.traverseAncestorsIncludingSelf( -// type = Nodes.KeyInput, -// onPreVisit = { if (it.onPreKeyEvent(keyEvent)) return true }, -// onVisit = { if (onFocusedItem.invoke()) return true }, -// onPostVisit = { if (it.onKeyEvent(keyEvent)) return true } -// ) -// return false -// } + /** + * Dispatches a key event through the compose hierarchy. + */ + override fun dispatchKeyEvent(keyEvent: KeyEvent, onFocusedItem: () -> Boolean): Boolean { + if (focusInvalidationManager.hasPendingInvalidation()) { + // Ignoring this to unblock b/346370327. + println("$Warning: Dispatching key event while focus system is invalidated.") + return false + } + if (!validateKeyEvent(keyEvent)) return false + + val activeFocusTarget = rootFocusNode.findActiveFocusNode() + val focusedKeyInputNode = activeFocusTarget?.lastLocalKeyInputNode() + ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node + ?: rootFocusNode.nearestAncestor(Nodes.KeyInput)?.node + + focusedKeyInputNode?.traverseAncestorsIncludingSelf( + type = Nodes.KeyInput, + onPreVisit = { if (it.onPreKeyEvent(keyEvent)) return true }, + onVisit = { if (onFocusedItem.invoke()) return true }, + onPostVisit = { if (it.onKeyEvent(keyEvent)) return true } + ) + return false + } // // @OptIn(ExperimentalComposeUiApi::class) // override fun dispatchInterceptedSoftKeyboardEvent(keyEvent: KeyEvent): Boolean { @@ -381,38 +379,38 @@ internal class FocusOwnerImpl( override val rootState: FocusState get() = rootFocusNode.focusState -// private fun DelegatableNode.lastLocalKeyInputNode(): Modifier.Node? { -// var focusedKeyInputNode: Modifier.Node? = null -// visitLocalDescendants(Nodes.FocusTarget or Nodes.KeyInput) { modifierNode -> -// if (modifierNode.isKind(Nodes.FocusTarget)) return focusedKeyInputNode -// -// focusedKeyInputNode = modifierNode -// } -// return focusedKeyInputNode -// } -// -// // TODO(b/307580000) Factor this out into a class to manage key inputs. -// private fun validateKeyEvent(keyEvent: KeyEvent): Boolean { -// val keyCode = keyEvent.key.keyCode -// when (keyEvent.type) { -// KeyDown -> { -// // It's probably rare for more than 3 hardware keys to be pressed simultaneously. -// val keysCurrentlyDown = keysCurrentlyDown ?: MutableLongSet(initialCapacity = 3) -// .also { keysCurrentlyDown = it } -// keysCurrentlyDown += keyCode -// } -// -// KeyUp -> { -// if (keysCurrentlyDown?.contains(keyCode) != true) { -// // An UP event for a key that was never DOWN is invalid, ignore it. -// return false -// } -// keysCurrentlyDown?.remove(keyCode) -// } -// // Always process Unknown event types. -// } -// return true -// } + private fun DelegatableNode.lastLocalKeyInputNode(): Modifier.Node? { + var focusedKeyInputNode: Modifier.Node? = null + visitLocalDescendants(Nodes.FocusTarget or Nodes.KeyInput) { modifierNode -> + if (modifierNode.isKind(Nodes.FocusTarget)) return focusedKeyInputNode + + focusedKeyInputNode = modifierNode + } + return focusedKeyInputNode + } + + // TODO(b/307580000) Factor this out into a class to manage key inputs. + private fun validateKeyEvent(keyEvent: KeyEvent): Boolean { + val keyCode = keyEvent.key.keyCode + when (keyEvent.type) { + KeyDown -> { + // It's probably rare for more than 3 hardware keys to be pressed simultaneously. + val keysCurrentlyDown = keysCurrentlyDown ?: MutableLongSet(initialCapacity = 3) + .also { keysCurrentlyDown = it } + keysCurrentlyDown += keyCode + } + + KeyUp -> { + if (keysCurrentlyDown?.contains(keyCode) != true) { + // An UP event for a key that was never DOWN is invalid, ignore it. + return false + } + keysCurrentlyDown?.remove(keyCode) + } + // Always process Unknown event types. + } + return true + } } /** diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt index 90aaa1f53..e556f1ee9 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt @@ -16,12 +16,6 @@ package com.tencent.kuikly.compose.ui.input.key -/** - * The native platform-specific keyboard key event. - */ -//expect class NativeKeyEvent -typealias NativeKeyEvent = Any - /** * When a user presses a key on a hardware keyboard, a [KeyEvent] is sent to the item that is * currently focused. Any parent composable can intercept this [key event][KeyEvent] on its way to @@ -31,106 +25,56 @@ typealias NativeKeyEvent = Any * * @sample androidx.compose.ui.samples.KeyEventSample */ +data class KeyEvent( + val key: Key, + val type: KeyEventType = KeyEventType.Unknown, + val utf16CodePoint: Int = 0, + val isAltPressed: Boolean = false, + val isCtrlPressed: Boolean = false, + val isMetaPressed: Boolean = false, + val isShiftPressed: Boolean = false, + val nativeKeyEvent: Any? = null +) { + companion object +} + +/** + * The type of Key Event. + * + * @sample androidx.compose.ui.samples.KeyEventTypeSample + */ @kotlin.jvm.JvmInline -value class KeyEvent(val nativeKeyEvent: NativeKeyEvent) +value class KeyEventType internal constructor(@Suppress("unused") private val value: Int) { + + override fun toString(): String { + return when (this) { + KeyUp -> "KeyUp" + KeyDown -> "KeyDown" + Unknown -> "Unknown" + else -> "Invalid" + } + } + + companion object { + /** + * Unknown key event. + * + * @sample androidx.compose.ui.samples.KeyEventTypeSample + */ + val Unknown: KeyEventType = KeyEventType(0) + + /** + * Type of KeyEvent sent when the user lifts their finger off a key on the keyboard. + * + * @sample androidx.compose.ui.samples.KeyEventTypeSample + */ + val KeyUp: KeyEventType = KeyEventType(1) -///** -// * The key that was pressed. -// * -// * @sample androidx.compose.ui.samples.KeyEventIsAltPressedSample -// */ -//val KeyEvent.key: Key get() = Key(nativeKeyEvent.key) -// -///** -// * The UTF16 value corresponding to the key event that was pressed. The unicode character -// * takes into account any meta keys that are pressed (eg. Pressing shift results in capital -// * alphabets). The UTF16 value uses the -// * [U+n notation][http://www.unicode.org/reports/tr27/#notation] of the Unicode Standard. -// * -// * An [Int] is used instead of a [Char] so that we can support supplementary characters. The -// * Unicode Standard allows for characters whose representation requires more than 16 bits. -// * The range of legal code points is U+0000 to U+10FFFF, known as Unicode scalar value. -// * -// * The set of characters from U+0000 to U+FFFF is sometimes referred to as the Basic -// * Multilingual Plane (BMP). Characters whose code points are greater than U+FFFF are called -// * supplementary characters. In this representation, supplementary characters are represented -// * as a pair of char values, the first from the high-surrogates range, (\uD800-\uDBFF), the -// * second from the low-surrogates range (\uDC00-\uDFFF). -// */ -//expect val KeyEvent.utf16CodePoint: Int -// -///** -// * The [type][KeyEventType] of key event. -// * -// * @sample androidx.compose.ui.samples.KeyEventTypeSample -// */ -//expect val KeyEvent.type: KeyEventType -// -///** -// * Indicates whether the Alt key is pressed. -// * -// * @sample androidx.compose.ui.samples.KeyEventIsAltPressedSample -// */ -//expect val KeyEvent.isAltPressed: Boolean -// -///** -// * Indicates whether the Ctrl key is pressed. -// * -// * @sample androidx.compose.ui.samples.KeyEventIsCtrlPressedSample -// */ -//expect val KeyEvent.isCtrlPressed: Boolean -// -///** -// * Indicates whether the Meta key is pressed. -// * -// * @sample androidx.compose.ui.samples.KeyEventIsMetaPressedSample -// */ -//expect val KeyEvent.isMetaPressed: Boolean -// -///** -// * Indicates whether the Shift key is pressed. -// * -// * @sample androidx.compose.ui.samples.KeyEventIsShiftPressedSample -// */ -//expect val KeyEvent.isShiftPressed: Boolean -// -///** -// * The type of Key Event. -// * -// * @sample androidx.compose.ui.samples.KeyEventTypeSample -// */ -//@kotlin.jvm.JvmInline -//value class KeyEventType internal constructor(@Suppress("unused") private val value: Int) { -// -// override fun toString(): String { -// return when (this) { -// KeyUp -> "KeyUp" -// KeyDown -> "KeyDown" -// Unknown -> "Unknown" -// else -> "Invalid" -// } -// } -// -// companion object { -// /** -// * Unknown key event. -// * -// * @sample androidx.compose.ui.samples.KeyEventTypeSample -// */ -// val Unknown: KeyEventType = KeyEventType(0) -// -// /** -// * Type of KeyEvent sent when the user lifts their finger off a key on the keyboard. -// * -// * @sample androidx.compose.ui.samples.KeyEventTypeSample -// */ -// val KeyUp: KeyEventType = KeyEventType(1) -// -// /** -// * Type of KeyEvent sent when the user presses down their finger on a key on the keyboard. -// * -// * @sample androidx.compose.ui.samples.KeyEventTypeSample -// */ -// val KeyDown: KeyEventType = KeyEventType(2) -// } -//} + /** + * Type of KeyEvent sent when the user presses down their finger on a key on the keyboard. + * + * @sample androidx.compose.ui.samples.KeyEventTypeSample + */ + val KeyDown: KeyEventType = KeyEventType(2) + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyInputModifier.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyInputModifier.kt index 3e9a7fea9..929a1317d 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyInputModifier.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyInputModifier.kt @@ -17,6 +17,7 @@ package com.tencent.kuikly.compose.ui.input.key import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.node.DelegatableNode import com.tencent.kuikly.compose.ui.node.ModifierNodeElement import com.tencent.kuikly.compose.ui.platform.InspectorInfo @@ -30,9 +31,9 @@ import com.tencent.kuikly.compose.ui.platform.InspectorInfo * * @sample androidx.compose.ui.samples.KeyEventSample */ -fun Modifier.onKeyEvent( // todo pel: noop currently, need render support +fun Modifier.onKeyEvent( onKeyEvent: (KeyEvent) -> Boolean -): Modifier = this // then KeyInputElement(onKeyEvent = onKeyEvent, onPreKeyEvent = null) +): Modifier = this then KeyInputElement(onKeyEvent = onKeyEvent, onPreKeyEvent = null) /** * Adding this [modifier][Modifier] to the [modifier][Modifier] parameter of a component will @@ -46,37 +47,43 @@ fun Modifier.onKeyEvent( // todo pel: noop currently, need render support * * @sample androidx.compose.ui.samples.KeyEventSample */ -fun Modifier.onPreviewKeyEvent( // todo pel: noop currently, need render support +fun Modifier.onPreviewKeyEvent( onPreviewKeyEvent: (KeyEvent) -> Boolean -): Modifier = this // then KeyInputElement(onKeyEvent = null, onPreKeyEvent = onPreviewKeyEvent) +): Modifier = this then KeyInputElement(onKeyEvent = null, onPreKeyEvent = onPreviewKeyEvent) -//internal data class KeyInputElement( -// val onKeyEvent: ((KeyEvent) -> Boolean)?, -// val onPreKeyEvent: ((KeyEvent) -> Boolean)? -//) : ModifierNodeElement() { -// override fun create() = KeyInputNode(onKeyEvent, onPreKeyEvent) -// -// override fun update(node: KeyInputNode) { -// node.onEvent = onKeyEvent -// node.onPreEvent = onPreKeyEvent -// } -// -// override fun InspectorInfo.inspectableProperties() { -// onKeyEvent?.let { -// name = "onKeyEvent" -// properties["onKeyEvent"] = it -// } -// onPreKeyEvent?.let { -// name = "onPreviewKeyEvent" -// properties["onPreviewKeyEvent"] = it -// } -// } -//} -// -//internal class KeyInputNode( -// var onEvent: ((KeyEvent) -> Boolean)?, -// var onPreEvent: ((KeyEvent) -> Boolean)? -//) : KeyInputModifierNode, Modifier.Node() { -// override fun onKeyEvent(event: KeyEvent): Boolean = this.onEvent?.invoke(event) ?: false -// override fun onPreKeyEvent(event: KeyEvent): Boolean = this.onPreEvent?.invoke(event) ?: false -//} +interface KeyInputModifierNode : DelegatableNode { + fun onKeyEvent(event: KeyEvent): Boolean + + fun onPreKeyEvent(event: KeyEvent): Boolean +} + +internal data class KeyInputElement( + val onKeyEvent: ((KeyEvent) -> Boolean)?, + val onPreKeyEvent: ((KeyEvent) -> Boolean)? +) : ModifierNodeElement() { + override fun create() = KeyInputNode(onKeyEvent, onPreKeyEvent) + + override fun update(node: KeyInputNode) { + node.onEvent = onKeyEvent + node.onPreEvent = onPreKeyEvent + } + + override fun InspectorInfo.inspectableProperties() { + onKeyEvent?.let { + name = "onKeyEvent" + properties["onKeyEvent"] = it + } + onPreKeyEvent?.let { + name = "onPreviewKeyEvent" + properties["onPreviewKeyEvent"] = it + } + } +} + +internal class KeyInputNode( + var onEvent: ((KeyEvent) -> Boolean)?, + var onPreEvent: ((KeyEvent) -> Boolean)? +) : KeyInputModifierNode, Modifier.Node() { + override fun onKeyEvent(event: KeyEvent): Boolean = this.onEvent?.invoke(event) ?: false + override fun onPreKeyEvent(event: KeyEvent): Boolean = this.onPreEvent?.invoke(event) ?: false +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeKind.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeKind.kt index a8a921d4c..ec22f3c85 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeKind.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/NodeKind.kt @@ -27,6 +27,7 @@ import com.tencent.kuikly.compose.ui.focus.FocusTargetNode import com.tencent.kuikly.compose.ui.focus.invalidateFocusEvent import com.tencent.kuikly.compose.ui.focus.invalidateFocusProperties import com.tencent.kuikly.compose.ui.focus.invalidateFocusTarget +import com.tencent.kuikly.compose.ui.input.key.KeyInputModifierNode import com.tencent.kuikly.compose.ui.input.pointer.PointerInputModifier import com.tencent.kuikly.compose.ui.internal.checkPrecondition import com.tencent.kuikly.compose.ui.internal.checkPreconditionNotNull @@ -94,8 +95,8 @@ internal object Nodes { inline val FocusProperties get() = NodeKind(0b1 shl 11) @JvmStatic inline val FocusEvent get() = NodeKind(0b1 shl 12) -// @JvmStatic -// inline val KeyInput get() = NodeKind(0b1 shl 13) + @JvmStatic + inline val KeyInput get() = NodeKind(0b1 shl 13) // @JvmStatic // inline val RotaryInput get() = NodeKind(0b1 shl 14) @JvmStatic @@ -205,9 +206,9 @@ internal fun calculateNodeKindSetFrom(node: Modifier.Node): Int { if (node is FocusEventModifierNode) { mask = mask or Nodes.FocusEvent } -// if (node is KeyInputModifierNode) { -// mask = mask or Nodes.KeyInput -// } + if (node is KeyInputModifierNode) { + mask = mask or Nodes.KeyInput + } // if (node is RotaryInputModifierNode) { // mask = mask or Nodes.RotaryInput // } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/BaseComposeScene.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/BaseComposeScene.kt index 8c4c1e4c2..865218d4c 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/BaseComposeScene.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/BaseComposeScene.kt @@ -35,6 +35,7 @@ import com.tencent.kuikly.compose.ui.GlobalSnapshotManager import com.tencent.kuikly.compose.ui.InternalComposeUiApi import com.tencent.kuikly.compose.ui.geometry.Offset import com.tencent.kuikly.compose.ui.graphics.Canvas +import com.tencent.kuikly.compose.ui.input.key.KeyEvent import com.tencent.kuikly.compose.ui.input.pointer.PointerButton import com.tencent.kuikly.compose.ui.input.pointer.PointerEventType import com.tencent.kuikly.compose.ui.input.pointer.PointerInputEvent @@ -261,6 +262,10 @@ internal abstract class BaseComposeScene( throwRuntimeError("invalid invoke") } + override fun sendKeyEvent(keyEvent: KeyEvent): Boolean = postponeInvalidation { + processKeyEvent(keyEvent) + } + private fun doLayout() { snapshotInvalidationTracker.onMeasureAndLayout() measureAndLayout() @@ -308,6 +313,8 @@ internal abstract class BaseComposeScene( protected abstract fun processPointerInputEvent(event: PointerInputEvent) + protected abstract fun processKeyEvent(event: KeyEvent): Boolean + protected abstract fun measureAndLayout() protected abstract fun draw(canvas: Canvas) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/ComposeScene.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/ComposeScene.kt index 77b6a0661..4a808f218 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/ComposeScene.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/ComposeScene.kt @@ -26,6 +26,7 @@ import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.InternalComposeUiApi import com.tencent.kuikly.compose.ui.geometry.Offset import com.tencent.kuikly.compose.ui.graphics.Canvas +import com.tencent.kuikly.compose.ui.input.key.KeyEvent import com.tencent.kuikly.compose.ui.input.pointer.PointerButton import com.tencent.kuikly.compose.ui.input.pointer.PointerEventType import com.tencent.kuikly.compose.ui.input.pointer.PointerType @@ -150,6 +151,14 @@ interface ComposeScene { nanoTime: Long, ) + /** + * Send a hardware key event to the content. + * + * The event is routed through focus first with preview handlers from ancestors to the focused + * item, then normal handlers from the focused item back to ancestors. + */ + fun sendKeyEvent(keyEvent: KeyEvent): Boolean + /** * Send pointer event to the content. * diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/KuiklyComposeScene.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/KuiklyComposeScene.kt index ef9ba2fa3..f516e076d 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/KuiklyComposeScene.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/scene/KuiklyComposeScene.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition import com.tencent.kuikly.compose.ui.InternalComposeUiApi import com.tencent.kuikly.compose.ui.graphics.Canvas +import com.tencent.kuikly.compose.ui.input.key.KeyEvent import com.tencent.kuikly.compose.ui.input.pointer.PointerInputEvent import com.tencent.kuikly.compose.ui.node.RootNodeOwner import com.tencent.kuikly.compose.ui.platform.setContent @@ -131,6 +132,9 @@ private class KuiklyComposeSceneImpl @InternalComposeUiApi constructor( override fun processPointerInputEvent(event: PointerInputEvent) = mainOwner.onPointerInput(event) + override fun processKeyEvent(event: KeyEvent): Boolean = + mainOwner.focusOwner.dispatchKeyEvent(event) + override fun measureAndLayout() { mainOwner.measureAndLayout() } @@ -145,4 +149,3 @@ private class KuiklyComposeSceneImpl @InternalComposeUiApi constructor( private fun onOwnerRemoved(owner: RootNodeOwner) { } } - diff --git a/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt new file mode 100644 index 000000000..a7f3d5c78 --- /dev/null +++ b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt @@ -0,0 +1,65 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.input.key + +/** + * Build a Kuikly Compose key event from a native-family platform key code. + * + * iOS, macOS and OHOS hosts can use this when they already normalize their native key code to the + * same key-code space as [Key] on Kotlin/Native targets. + */ +fun nativePlatformKeyEvent( + keyCode: Long, + type: KeyEventType = KeyEventType.Unknown, + utf16CodePoint: Int = 0, + isAltPressed: Boolean = false, + isCtrlPressed: Boolean = false, + isMetaPressed: Boolean = false, + isShiftPressed: Boolean = false, + nativeKeyEvent: Any? = null, +): KeyEvent = + KeyEvent( + key = Key(keyCode), + type = type, + utf16CodePoint = utf16CodePoint, + isAltPressed = isAltPressed, + isCtrlPressed = isCtrlPressed, + isMetaPressed = isMetaPressed, + isShiftPressed = isShiftPressed, + nativeKeyEvent = nativeKeyEvent, + ) +/** + * Build a Kuikly Compose key event from a [SkikoKey] value used by Kuikly native targets. + */ +fun SkikoKey.toComposeKeyEvent( + type: KeyEventType = KeyEventType.Unknown, + utf16CodePoint: Int = 0, + isAltPressed: Boolean = false, + isCtrlPressed: Boolean = false, + isMetaPressed: Boolean = false, + isShiftPressed: Boolean = false, + nativeKeyEvent: Any? = null, +): KeyEvent = + nativePlatformKeyEvent( + keyCode = platformKeyCode.toLong(), + type = type, + utf16CodePoint = utf16CodePoint, + isAltPressed = isAltPressed, + isCtrlPressed = isCtrlPressed, + isMetaPressed = isMetaPressed, + isShiftPressed = isShiftPressed, + nativeKeyEvent = nativeKeyEvent, + ) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/KuiklyRenderView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/KuiklyRenderView.kt index 93e3dff40..bc39001a5 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/KuiklyRenderView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/KuiklyRenderView.kt @@ -27,6 +27,7 @@ import android.util.Log import android.util.Size import android.util.SizeF import android.util.SparseArray +import android.view.KeyEvent import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager @@ -584,6 +585,25 @@ class KuiklyRenderView( sendEvent(ON_BACK_PRESSED, mapOf()) } + fun sendKeyEvent(event: KeyEvent) { + sendEvent( + KEY_EVENT, + mapOf( + KEY_EVENT_KEY_CODE to event.keyCode, + KEY_EVENT_TYPE to when (event.action) { + KeyEvent.ACTION_UP -> KEY_EVENT_TYPE_UP + KeyEvent.ACTION_DOWN -> KEY_EVENT_TYPE_DOWN + else -> KEY_EVENT_TYPE_UNKNOWN + }, + KEY_EVENT_UTF16_CODE_POINT to event.unicodeChar, + KEY_EVENT_ALT_PRESSED to event.isAltPressed, + KEY_EVENT_CTRL_PRESSED to event.isCtrlPressed, + KEY_EVENT_META_PRESSED to event.isMetaPressed, + KEY_EVENT_SHIFT_PRESSED to event.isShiftPressed, + ) + ) + } + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { if (delegate?.debugLogEnable() == true) { if (requestedLayout) { @@ -658,6 +678,17 @@ class KuiklyRenderView( private const val ACCESSIBILITY_RUNNING = "isAccessibilityRunning" // 无障碍化是否开启 private const val ON_BACK_PRESSED = "onBackPressed" + private const val KEY_EVENT = "keyEvent" + private const val KEY_EVENT_KEY_CODE = "keyCode" + private const val KEY_EVENT_TYPE = "type" + private const val KEY_EVENT_TYPE_UNKNOWN = 0 + private const val KEY_EVENT_TYPE_UP = 1 + private const val KEY_EVENT_TYPE_DOWN = 2 + private const val KEY_EVENT_UTF16_CODE_POINT = "utf16CodePoint" + private const val KEY_EVENT_ALT_PRESSED = "altPressed" + private const val KEY_EVENT_CTRL_PRESSED = "ctrlPressed" + private const val KEY_EVENT_META_PRESSED = "metaPressed" + private const val KEY_EVENT_SHIFT_PRESSED = "shiftPressed" // RenderView 生命周期状态 private const val STATE_INIT = 0 @@ -1056,4 +1087,4 @@ class KuiklyRenderExport(private val renderContext: IKuiklyRenderContext) : IKui private typealias InitRenderCoreLazyTask = (size: SizeF) -> Unit private typealias RenderCoreLazyEvent = Pair> -private typealias RenderCoreLazyTask = (core: IKuiklyRenderCore) -> Unit \ No newline at end of file +private typealias RenderCoreLazyTask = (core: IKuiklyRenderCore) -> Unit diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/KuiklyRenderViewBaseDelegator.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/KuiklyRenderViewBaseDelegator.kt index 553f1e141..87d141551 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/KuiklyRenderViewBaseDelegator.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/KuiklyRenderViewBaseDelegator.kt @@ -18,6 +18,7 @@ package com.tencent.kuikly.core.render.android.expand import android.content.Context import android.content.Intent import android.util.Size +import android.view.KeyEvent import android.view.LayoutInflater import android.view.ViewGroup import android.view.WindowManager @@ -550,6 +551,16 @@ open class KuiklyRenderViewBaseDelegator(private val delegate: KuiklyRenderViewB return isBackPressedConsumed.get() } + /** + * Dispatch a hardware key event to a Kuikly Compose page. + * + * Apps can call this from Activity.dispatchKeyEvent. Compose pages receive it through + * Modifier.onPreviewKeyEvent/Modifier.onKeyEvent when the focused tree has key handlers. + */ + fun sendKeyEvent(event: KeyEvent) { + renderView?.sendKeyEvent(event) + } + } private typealias KuiklyRenderViewPendingTask = (KuiklyRenderView) -> Unit @@ -683,4 +694,4 @@ interface KuiklyRenderViewBaseDelegatorDelegate { fun debugLogEnable(): Boolean { return false } -} \ No newline at end of file +} diff --git a/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.h b/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.h index 170256123..9784b1996 100644 --- a/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.h +++ b/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.h @@ -130,6 +130,19 @@ FOUNDATION_EXTERN NSString *const KRPageDataSnapshotKey; */ - (void)onBackPressedWithCompletion:(nullable KuiklyBackPressCompletion)completion; +/* + * @brief 向 Kuikly Compose 页面发送硬件键盘事件. + * @param keyCode 平台 key code,对应 Kuikly Compose Native Key.keyCode 编码 + * @param type 事件类型:0 unknown,1 key up,2 key down + */ +- (void)sendKeyEventWithKeyCode:(NSInteger)keyCode + type:(NSInteger)type + utf16CodePoint:(NSInteger)utf16CodePoint + altPressed:(BOOL)altPressed + ctrlPressed:(BOOL)ctrlPressed + metaPressed:(BOOL)metaPressed + shiftPressed:(BOOL)shiftPressed; + @end @protocol KuiklyRenderViewControllerBaseDelegatorDelegate @@ -304,4 +317,3 @@ FOUNDATION_EXTERN NSString *const KRPageDataSnapshotKey; NS_ASSUME_NONNULL_END - diff --git a/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.m b/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.m index 3c009a425..ae4362e5c 100644 --- a/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.m +++ b/core-render-ios/Extension/KuiklyRenderViewControllerBaseDelegator.m @@ -148,6 +148,25 @@ - (void)sendWithEvent:(NSString *)event data:(NSDictionary *)data { } } +- (void)sendKeyEventWithKeyCode:(NSInteger)keyCode + type:(NSInteger)type + utf16CodePoint:(NSInteger)utf16CodePoint + altPressed:(BOOL)altPressed + ctrlPressed:(BOOL)ctrlPressed + metaPressed:(BOOL)metaPressed + shiftPressed:(BOOL)shiftPressed { + [self sendWithEvent:@"keyEvent" + data:@{ + @"keyCode": @(keyCode), + @"type": @(type), + @"utf16CodePoint": @(utf16CodePoint), + @"altPressed": @(altPressed), + @"ctrlPressed": @(ctrlPressed), + @"metaPressed": @(metaPressed), + @"shiftPressed": @(shiftPressed), + }]; +} + - (BOOL)syncSendEvent:(NSString *)event { // onBackPressed 固定同步执行 if ([event isEqualToString:@"onBackPressed"]) { @@ -568,4 +587,3 @@ - (void)dealloc { @end - diff --git a/core-render-ios/View/KuiklyRenderView.h b/core-render-ios/View/KuiklyRenderView.h index bf659ebc1..dbda2380e 100644 --- a/core-render-ios/View/KuiklyRenderView.h +++ b/core-render-ios/View/KuiklyRenderView.h @@ -73,6 +73,19 @@ FOUNDATION_EXTERN NSString *const KRRootViewSizeDidChangedEventKey; */ - (void)sendWithEvent:(NSString *)event data:(NSDictionary *)data sync:(BOOL)sync; +/* + * @brief 向 Kuikly Compose 页面发送硬件键盘事件. + * @param keyCode 平台 key code,对应 Kuikly Compose Native Key.keyCode 编码 + * @param type 事件类型:0 unknown,1 key up,2 key down + */ +- (void)sendKeyEventWithKeyCode:(NSInteger)keyCode + type:(NSInteger)type + utf16CodePoint:(NSInteger)utf16CodePoint + altPressed:(BOOL)altPressed + ctrlPressed:(BOOL)ctrlPressed + metaPressed:(BOOL)metaPressed + shiftPressed:(BOOL)shiftPressed; + /* * @brief 获取模块对应的实例(仅支持在主线程调用). * @param moduleName 模块名 @@ -169,4 +182,3 @@ FOUNDATION_EXTERN NSString *const KRRootViewSizeDidChangedEventKey; @end NS_ASSUME_NONNULL_END - diff --git a/core-render-ios/View/KuiklyRenderView.m b/core-render-ios/View/KuiklyRenderView.m index 1e327c103..ccf78ab64 100644 --- a/core-render-ios/View/KuiklyRenderView.m +++ b/core-render-ios/View/KuiklyRenderView.m @@ -92,6 +92,26 @@ - (void)sendWithEvent:(NSString *)event data:(NSDictionary *)data { - (void)sendWithEvent:(NSString *)event data:(NSDictionary *)data sync:(BOOL)sync { [_renderCore sendWithEvent:event data:data sync:sync]; } + +- (void)sendKeyEventWithKeyCode:(NSInteger)keyCode + type:(NSInteger)type + utf16CodePoint:(NSInteger)utf16CodePoint + altPressed:(BOOL)altPressed + ctrlPressed:(BOOL)ctrlPressed + metaPressed:(BOOL)metaPressed + shiftPressed:(BOOL)shiftPressed { + [self sendWithEvent:@"keyEvent" + data:@{ + @"keyCode": @(keyCode), + @"type": @(type), + @"utf16CodePoint": @(utf16CodePoint), + @"altPressed": @(altPressed), + @"ctrlPressed": @(ctrlPressed), + @"metaPressed": @(metaPressed), + @"shiftPressed": @(shiftPressed), + }]; +} + /* * @brief 获取模块对应的实例(仅支持在主线程调用). * @param moduleName 模块名 @@ -355,4 +375,3 @@ - (void)dealloc { } @end - diff --git a/core-render-ohos/src/main/ets/IKuiklyRenderView.ets b/core-render-ohos/src/main/ets/IKuiklyRenderView.ets index e68d5cde1..7cc98a85d 100644 --- a/core-render-ohos/src/main/ets/IKuiklyRenderView.ets +++ b/core-render-ohos/src/main/ets/IKuiklyRenderView.ets @@ -38,6 +38,21 @@ export interface IKuiklyRenderView { */ sendEventSync(event: string, data: KRRecord, sync: boolean): void; + /** + * Send a hardware key event to a Kuikly Compose page. + * @param keyCode platform key code normalized to Kuikly Compose Native Key.keyCode + * @param type event type: 0 unknown, 1 key up, 2 key down + */ + sendKeyEvent( + keyCode: number, + type: number, + utf16CodePoint?: number, + altPressed?: boolean, + ctrlPressed?: boolean, + metaPressed?: boolean, + shiftPressed?: boolean + ): void; + /** * 获取 [KuiklyRenderBaseModule] * @param name module 的名字 diff --git a/core-render-ohos/src/main/ets/KRNativeRenderController.ets b/core-render-ohos/src/main/ets/KRNativeRenderController.ets index df889031d..17efe226c 100644 --- a/core-render-ohos/src/main/ets/KRNativeRenderController.ets +++ b/core-render-ohos/src/main/ets/KRNativeRenderController.ets @@ -525,6 +525,19 @@ export class KRNativeRenderController { this.doSendEvent(event, data, sync); } + sendKeyEvent(keyCode: number, type: number, utf16CodePoint?: number, altPressed?: boolean, + ctrlPressed?: boolean, metaPressed?: boolean, shiftPressed?: boolean) { + this.doSendEvent('keyEvent', { + 'keyCode': keyCode, + 'type': type, + 'utf16CodePoint': utf16CodePoint ?? 0, + 'altPressed': altPressed ?? false, + 'ctrlPressed': ctrlPressed ?? false, + 'metaPressed': metaPressed ?? false, + 'shiftPressed': shiftPressed ?? false, + }); + } + onBackPress() { const sendTime = Date.now(); this.doSendEvent(KROnBackPressedKey, {}, this.syncSendEvent(KROnBackPressedKey)); @@ -923,4 +936,4 @@ export enum KRMonitorType { LAUNCH = 1 << 0, // 1 启动监控 FRAME = 1 << 1, // 2 FPS监控 MEMORY = 1 << 2, // 4 内存监控 -} \ No newline at end of file +} From c1d4192b27dd09a34b3347f7df140ac07d1cb25c Mon Sep 17 00:00:00 2001 From: Codex-Kuikly-KMP Date: Sun, 5 Jul 2026 20:52:34 +0800 Subject: [PATCH 051/187] fix(compose): address key event review Signed-off-by: Codex-Kuikly-KMP --- .../kuikly/compose/ComposeContainer.kt | 6 ++-- .../kuikly/compose/ui/focus/FocusOwnerImpl.kt | 30 ++++++++-------- .../kuikly/compose/ui/input/key/KeyEvent.kt | 35 ++++++++++++++++--- .../compose/ui/input/key/KeyEvent.native.kt | 2 +- 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt index 13cf0c978..23abfbb16 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ComposeContainer.kt @@ -91,9 +91,9 @@ open class ComposeContainer : const val KEY_EVENT_KEY_CODE = "keyCode" const val KEY_EVENT_TYPE = "type" - const val KEY_EVENT_TYPE_UNKNOWN = 0 - const val KEY_EVENT_TYPE_UP = 1 - const val KEY_EVENT_TYPE_DOWN = 2 + const val KEY_EVENT_TYPE_UNKNOWN = KeyEventType.UnknownValue + const val KEY_EVENT_TYPE_UP = KeyEventType.KeyUpValue + const val KEY_EVENT_TYPE_DOWN = KeyEventType.KeyDownValue const val KEY_EVENT_UTF16_CODE_POINT = "utf16CodePoint" const val KEY_EVENT_ALT_PRESSED = "altPressed" const val KEY_EVENT_CTRL_PRESSED = "ctrlPressed" diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt index 5b0b4fbe9..2f091dd6b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/focus/FocusOwnerImpl.kt @@ -269,13 +269,12 @@ internal class FocusOwnerImpl( ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node ?: rootFocusNode.nearestAncestor(Nodes.KeyInput)?.node - focusedKeyInputNode?.traverseAncestorsIncludingSelf( + return focusedKeyInputNode?.traverseAncestorsIncludingSelf( type = Nodes.KeyInput, - onPreVisit = { if (it.onPreKeyEvent(keyEvent)) return true }, - onVisit = { if (onFocusedItem.invoke()) return true }, - onPostVisit = { if (it.onKeyEvent(keyEvent)) return true } - ) - return false + onPreVisit = { it.onPreKeyEvent(keyEvent) }, + onVisit = onFocusedItem, + onPostVisit = { it.onKeyEvent(keyEvent) } + ) ?: false } // // @OptIn(ExperimentalComposeUiApi::class) @@ -348,16 +347,17 @@ internal class FocusOwnerImpl( private inline fun DelegatableNode.traverseAncestorsIncludingSelf( type: NodeKind, - onPreVisit: (T) -> Unit, - onVisit: () -> Unit, - onPostVisit: (T) -> Unit - ) { + onPreVisit: (T) -> Boolean, + onVisit: () -> Boolean, + onPostVisit: (T) -> Boolean + ): Boolean { val ancestors = ancestors(type) - ancestors?.fastForEachReversed(onPreVisit) - node.dispatchForKind(type, onPreVisit) - onVisit.invoke() - node.dispatchForKind(type, onPostVisit) - ancestors?.fastForEach(onPostVisit) + ancestors?.fastForEachReversed { if (onPreVisit(it)) return true } + node.dispatchForKind(type) { if (onPreVisit(it)) return true } + if (onVisit.invoke()) return true + node.dispatchForKind(type) { if (onPostVisit(it)) return true } + ancestors?.fastForEach { if (onPostVisit(it)) return true } + return false } private inline fun DelegatableNode.nearestAncestorIncludingSelf( diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt index e556f1ee9..c2687d02b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.kt @@ -16,6 +16,11 @@ package com.tencent.kuikly.compose.ui.input.key +/** + * The native platform-specific keyboard key event. + */ +typealias NativeKeyEvent = Any + /** * When a user presses a key on a hardware keyboard, a [KeyEvent] is sent to the item that is * currently focused. Any parent composable can intercept this [key event][KeyEvent] on its way to @@ -26,15 +31,20 @@ package com.tencent.kuikly.compose.ui.input.key * @sample androidx.compose.ui.samples.KeyEventSample */ data class KeyEvent( - val key: Key, + val key: Key = Key.Unknown, val type: KeyEventType = KeyEventType.Unknown, val utf16CodePoint: Int = 0, val isAltPressed: Boolean = false, val isCtrlPressed: Boolean = false, val isMetaPressed: Boolean = false, val isShiftPressed: Boolean = false, - val nativeKeyEvent: Any? = null + val nativeKeyEvent: NativeKeyEvent = Unit ) { + constructor(nativeKeyEvent: NativeKeyEvent) : this( + key = Key.Unknown, + nativeKeyEvent = nativeKeyEvent + ) + companion object } @@ -56,25 +66,40 @@ value class KeyEventType internal constructor(@Suppress("unused") private val va } companion object { + /** + * Stable integer protocol value for an unknown key event. + */ + const val UnknownValue: Int = 0 + + /** + * Stable integer protocol value for a key-up event. + */ + const val KeyUpValue: Int = 1 + + /** + * Stable integer protocol value for a key-down event. + */ + const val KeyDownValue: Int = 2 + /** * Unknown key event. * * @sample androidx.compose.ui.samples.KeyEventTypeSample */ - val Unknown: KeyEventType = KeyEventType(0) + val Unknown: KeyEventType = KeyEventType(UnknownValue) /** * Type of KeyEvent sent when the user lifts their finger off a key on the keyboard. * * @sample androidx.compose.ui.samples.KeyEventTypeSample */ - val KeyUp: KeyEventType = KeyEventType(1) + val KeyUp: KeyEventType = KeyEventType(KeyUpValue) /** * Type of KeyEvent sent when the user presses down their finger on a key on the keyboard. * * @sample androidx.compose.ui.samples.KeyEventTypeSample */ - val KeyDown: KeyEventType = KeyEventType(2) + val KeyDown: KeyEventType = KeyEventType(KeyDownValue) } } diff --git a/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt index a7f3d5c78..ecde01276 100644 --- a/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt +++ b/compose/src/nativeMain/kotlin/com/tencent/kuikly/compose/ui/input/key/KeyEvent.native.kt @@ -39,7 +39,7 @@ fun nativePlatformKeyEvent( isCtrlPressed = isCtrlPressed, isMetaPressed = isMetaPressed, isShiftPressed = isShiftPressed, - nativeKeyEvent = nativeKeyEvent, + nativeKeyEvent = nativeKeyEvent ?: Unit, ) /** * Build a Kuikly Compose key event from a [SkikoKey] value used by Kuikly native targets. From b1babeec8b2172bf3cb340564c74ca26f3a888bc Mon Sep 17 00:00:00 2001 From: Cindy Date: Mon, 6 Jul 2026 01:50:20 +0800 Subject: [PATCH 052/187] fix(android): preserve inline code edge padding Signed-off-by: artin (cherry picked from commit cdd117b061c08c4e1ece4da43b34f2707da7c54d) Signed-off-by: Cindy (cherry picked from commit 6866174d607acd9044806a455fa0faed2dbdc1ad) Signed-off-by: Codex-Kuikly-KMP --- .../expand/component/text/KRRichTextBuilder.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index fcd87028f..43dc99a2b 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -502,7 +502,7 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In var index = start var firstAtom = true while (index < end) { - if (this[index].isWhitespace()) { + if (this[index].isSlockInlineCodeAtomBoundaryWhitespace()) { index++ continue } @@ -512,7 +512,7 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In } val textStart = index while (index < end && - !this[index].isWhitespace() && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && !this[index].isSlockInlineCodeBreakSeparator() ) { index++ @@ -528,7 +528,7 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In } val nextTextStart = index while (index < end && - !this[index].isWhitespace() && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && !this[index].isSlockInlineCodeBreakSeparator() ) { index++ @@ -556,10 +556,13 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In private fun Char.isSlockInlineCodeBreakSeparator(): Boolean = this == '/' || this == '\\' || this == '.' || this == '-' || this == ':' +private fun Char.isSlockInlineCodeAtomBoundaryWhitespace(): Boolean = + isWhitespace() || this == '\u00A0' + private fun CharSequence.hasSlockInlineCodeAtomAfter(start: Int, end: Int): Boolean { var index = start while (index < end) { - if (this[index].isWhitespace()) { + if (this[index].isSlockInlineCodeAtomBoundaryWhitespace()) { index++ continue } @@ -568,7 +571,7 @@ private fun CharSequence.hasSlockInlineCodeAtomAfter(start: Int, end: Int): Bool } val textStart = index while (index < end && - !this[index].isWhitespace() && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && !this[index].isSlockInlineCodeBreakSeparator() ) { index++ From d2954d582f582f80f16741a9b6b3092878a5d37d Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 6 Jul 2026 22:02:54 +0800 Subject: [PATCH 053/187] fix(ohos-render): add textarea newline edit menu (#3) fix(ohos-render): add textarea newline edit menu --- .../components/input/KRTextAreaView.cpp | 117 ++++++++++++++++++ .../expand/components/input/KRTextAreaView.h | 10 ++ .../expand/components/input/KRTextFieldView.h | 21 ++-- 3 files changed, 138 insertions(+), 10 deletions(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp index 2b3007d87..1025962ce 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp @@ -17,8 +17,13 @@ #include "libohos_render/utils/KRConvertUtil.h" #include "libohos_render/utils/KRViewUtil.h" #include "libohos_render/expand/components/input/KRTextAreaView.h" +#include +#include +#include constexpr char kLineHeight[] = "lineHeight"; +constexpr char kSlockSystemNewlineAction[] = "slockSystemNewlineAction"; +constexpr int32_t kSystemNewlineMenuItemId = ARKUI_TEXT_MENU_ITEM_ID_APP_RESERVED_BEGIN; void KRTextAreaView::DidInit() { // 调用父类的 DidInit 来设置默认样式(透明背景、无圆角、无padding) @@ -35,6 +40,14 @@ bool KRTextAreaView::SetProp(const std::string &prop_key, const KRAnyValue &prop kuikly::util::UpdateTextAreaNodeLineHeight(GetNode(), prop_value->toFloat()); return true; } + if (kuikly::util::isEqual(prop_key, kSlockSystemNewlineAction)) { + if (prop_value->toInt() == 1) { + SetupSystemNewlineEditMenu(); + } else { + TeardownSystemNewlineEditMenu(); + } + return true; + } return KRTextFieldView::SetProp(prop_key, prop_value, event_call_back); } @@ -137,3 +150,107 @@ void KRTextAreaView::UpdateInputNodeContentText(const std::string &text) { kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_TEXT, &item); } +void KRTextAreaView::OnDestroy() { + TeardownSystemNewlineEditMenu(); + KRTextFieldView::OnDestroy(); +} + +void KRTextAreaView::SetupSystemNewlineEditMenu() { + if (system_newline_edit_menu_options_ != nullptr) { + return; + } + auto options = OH_ArkUI_TextEditMenuOptions_Create(); + if (options == nullptr) { + return; + } + OH_ArkUI_TextEditMenuOptions_RegisterOnCreateMenuCallback( + options, this, KRTextAreaView::OnCreateSystemNewlineMenu); + OH_ArkUI_TextEditMenuOptions_RegisterOnMenuItemClickCallback( + options, this, KRTextAreaView::OnSystemNewlineMenuItemClick); + system_newline_edit_menu_options_ = options; + + ArkUI_AttributeItem item = {}; + item.object = options; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_EDIT_MENU_OPTIONS, &item); +} + +void KRTextAreaView::TeardownSystemNewlineEditMenu() { + if (system_newline_edit_menu_options_ == nullptr) { + return; + } + kuikly::util::GetNodeApi()->resetAttribute(GetNode(), NODE_TEXT_EDIT_MENU_OPTIONS); + OH_ArkUI_TextEditMenuOptions_Dispose(system_newline_edit_menu_options_); + system_newline_edit_menu_options_ = nullptr; +} + +void KRTextAreaView::OnCreateSystemNewlineMenu(ArkUI_TextMenuItemArray *items, void *userData) { + if (items == nullptr) { + return; + } + auto item = OH_ArkUI_TextMenuItem_Create(); + if (item == nullptr) { + return; + } + OH_ArkUI_TextMenuItem_SetId(item, kSystemNewlineMenuItemId); + OH_ArkUI_TextMenuItem_SetContent(item, "换行"); + + int32_t itemCount = 0; + if (OH_ArkUI_TextMenuItemArray_GetSize(items, &itemCount) != ARKUI_ERROR_CODE_NO_ERROR) { + itemCount = 0; + } + OH_ArkUI_TextMenuItemArray_Insert(items, item, itemCount); + OH_ArkUI_TextMenuItem_Dispose(item); +} + +bool KRTextAreaView::OnSystemNewlineMenuItemClick(const ArkUI_TextMenuItem *item, int32_t start, int32_t end, + void *userData) { + auto self = static_cast(userData); + if (self == nullptr || item == nullptr) { + return false; + } + int32_t itemId = 0; + if (OH_ArkUI_TextMenuItem_GetId(item, &itemId) != ARKUI_ERROR_CODE_NO_ERROR || + itemId != kSystemNewlineMenuItemId) { + return false; + } + self->InsertNewlineAtSelection(start, end); + return true; +} + +void KRTextAreaView::InsertNewlineAtSelection(int32_t start, int32_t end) { + std::string text; + if (auto content = kuikly::util::GetNodeApi()->getAttribute(GetNode(), NODE_TEXT_AREA_TEXT)) { + if (content->string != nullptr) { + text = content->string; + } + } + + int32_t rangeStart = start; + int32_t rangeEnd = end; + if (rangeStart < 0 || rangeEnd < 0) { + auto selection = GetInputNodeTextSelectionRange(); + rangeStart = static_cast(selection.first); + rangeEnd = static_cast(selection.second); + } + + int32_t u16Length = GetUTF16Length(text); + int32_t u16Start = std::min(rangeStart, rangeEnd); + int32_t u16End = std::max(rangeStart, rangeEnd); + u16Start = std::max(0, std::min(u16Start, u16Length)); + u16End = std::max(u16Start, std::min(u16End, u16Length)); + + auto u8Start = static_cast(GetUTF8ByteCount(text, 0, static_cast(u16Start))); + auto u8End = u8Start + static_cast( + GetUTF8ByteCount(text, u8Start, static_cast(u16End - u16Start))); + + std::string newText = text; + newText.replace(u8Start, u8End - u8Start, "\n"); + UpdateInputNodeContentText(newText); + + KRMainThread::RunOnMainThreadForNextLoop( + [weakSelf = weak_from_this(), caret = static_cast(u16Start + 1)]() { + if (auto strongSelf = std::dynamic_pointer_cast(weakSelf.lock())) { + strongSelf->UpdateInputNodeSelectionStartPosition(caret); + } + }); +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h index 2822f9e20..3d7ccca45 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h @@ -18,10 +18,12 @@ #include "libohos_render/expand/components/input/KRTextFieldView.h" #include "libohos_render/foundation/KRConfig.h" +#include class KRTextAreaView : public KRTextFieldView { public: void DidInit() override ; + void OnDestroy() override; ArkUI_NodeHandle CreateNode() override { return kuikly::util::GetNodeApi()->createNode(ARKUI_NODE_TEXT_AREA); @@ -63,6 +65,14 @@ class KRTextAreaView : public KRTextFieldView { void UpdateInputNodePlaceholderFont(uint32_t font_size, ArkUI_FontWeight font_weight) override; void UpdateInputNodeContentText(const std::string &text) override; + void SetupSystemNewlineEditMenu(); + void TeardownSystemNewlineEditMenu(); + void InsertNewlineAtSelection(int32_t start, int32_t end); + static void OnCreateSystemNewlineMenu(ArkUI_TextMenuItemArray *items, void *userData); + static bool OnSystemNewlineMenuItemClick(const ArkUI_TextMenuItem *item, int32_t start, int32_t end, + void *userData); + + ArkUI_TextEditMenuOptions *system_newline_edit_menu_options_ = nullptr; }; #endif // CORE_RENDER_OHOS_KRTEXTAREAVIEW_H diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h index 8184b44d4..6268ddbfd 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h @@ -92,6 +92,17 @@ class KRTextFieldView : public IKRRenderViewExport { virtual void UpdateInputNodeContentText(const std::string &text); virtual std::string GetInputNodeContentText(); + /** + * 获取text从u8Start到u16Count的UTF-8字节数 + * @param text 输入文本 + * @param u8Start UTF-8起始字节索引 + * @param u16Count UTF-16字符数量 + * @return 对应的UTF-8字节数 + */ + int GetUTF8ByteCount(const std::string &text, size_t u8Start, size_t u16Count); + + int GetUTF16Length(const std::string &text); + private: float font_size_ = 15; // default 15 ArkUI_FontWeight font_weight_ = ARKUI_FONT_WEIGHT_NORMAL; @@ -281,16 +292,6 @@ class KRTextFieldView : public IKRRenderViewExport { */ int GetVisualWidthOfCodePoint(char32_t codePoint); - /** - * 获取text从u8Start到u16Count的UTF-8字节数 - * @param text 输入文本 - * @param u8Start UTF-8起始字节索引 - * @param u16Count UTF-16字符数量 - * @return 对应的UTF-8字节数 - */ - int GetUTF8ByteCount(const std::string &text, size_t u8Start, size_t u16Count); - - int GetUTF16Length(const std::string &text); }; #endif // CORE_RENDER_OHOS_KRTEXTFIELDVIEW_H From 306c336f7463f6d0393fd661556478ceee7a8f55 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:19:46 +0800 Subject: [PATCH 054/187] fix(android): draw inline code trailing padding Signed-off-by: artin --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 91fef5bf8..7c2d38bfe 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -238,11 +238,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = if (segmentEnd == end) { - segmentRight - horizontalMargin - } else { - segmentRight + horizontalPadding - } + val right = segmentRight + horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From bc678aa8fdbba98abb4264994f33815ef2ba2adb Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:30:58 +0800 Subject: [PATCH 055/187] fix(android): expand inline code end chrome Signed-off-by: artin --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 7c2d38bfe..4437795de 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -238,7 +238,11 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = segmentRight + horizontalPadding + val right = if (segmentEnd == end) { + segmentRight + horizontalPadding + horizontalMargin + } else { + segmentRight + horizontalPadding + } if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From 73dfcf5570dc402e8bab5838eded42106fc9fd37 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:37:52 +0800 Subject: [PATCH 056/187] Revert "fix(android): expand inline code end chrome" This reverts commit bc678aa8fdbba98abb4264994f33815ef2ba2adb. --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 4437795de..7c2d38bfe 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -238,11 +238,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = if (segmentEnd == end) { - segmentRight + horizontalPadding + horizontalMargin - } else { - segmentRight + horizontalPadding - } + val right = segmentRight + horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From deabf29b9ded6c99571010c1a222ed74475deef4 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:39:59 +0800 Subject: [PATCH 057/187] fix(android): keep inline code margin outside chrome Signed-off-by: artin --- .../component/text/KRRichTextViewDrawer.kt | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 7c2d38bfe..1805fe09e 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -210,13 +210,17 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val end = spanned.getSpanEnd(span) if (start < 0 || end <= start) return@forEach - val startLine = textLayout.getLineForOffset(start) - val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) + val chromeStart = spanned.slockInlineCodeChromeStart(start, end) + val chromeEnd = spanned.slockInlineCodeChromeEnd(chromeStart, end) + if (chromeEnd <= chromeStart) return@forEach + + val startLine = textLayout.getLineForOffset(chromeStart) + val endLine = textLayout.getLineForOffset((chromeEnd - 1).coerceAtLeast(chromeStart)) for (line in startLine..endLine) { val lineStart = textLayout.getLineStart(line) val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) - val segmentStart = max(start, lineStart) - val segmentEnd = min(end, lineVisibleEnd) + val segmentStart = max(chromeStart, lineStart) + val segmentEnd = min(chromeEnd, lineVisibleEnd) if (segmentEnd <= segmentStart) continue val startX = @@ -233,12 +237,16 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } val segmentLeft = min(startX, endX) val segmentRight = max(startX, endX) - val left = if (segmentStart == start) { + val left = if (segmentStart == chromeStart) { segmentLeft + horizontalMargin } else { segmentLeft - horizontalPadding } - val right = segmentRight + horizontalPadding + val right = if (segmentEnd == chromeEnd) { + segmentRight - horizontalMargin + } else { + segmentRight + horizontalPadding + } if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() @@ -304,6 +312,25 @@ class KRRichTextViewDrawer(val textLayout: Layout) { return getLineVisibleEnd(line) } + private fun CharSequence.slockInlineCodeChromeStart(start: Int, end: Int): Int { + var index = start + while (index < end && this[index].isSlockInlineCodeChromeBoundaryWhitespace()) { + index++ + } + return index + } + + private fun CharSequence.slockInlineCodeChromeEnd(start: Int, end: Int): Int { + var index = end + while (index > start && this[index - 1].isSlockInlineCodeChromeBoundaryWhitespace()) { + index-- + } + return index + } + + private fun Char.isSlockInlineCodeChromeBoundaryWhitespace(): Boolean = + isWhitespace() || this == '\u00A0' + internal fun setSelectionByCoordinate( x: Float, y: Float, From 128fca3cc2c01c269246d29d18427b57b9030868 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:52:07 +0800 Subject: [PATCH 058/187] Revert "fix(android): keep inline code margin outside chrome" This reverts commit deabf29b9ded6c99571010c1a222ed74475deef4. --- .../component/text/KRRichTextViewDrawer.kt | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 1805fe09e..7c2d38bfe 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -210,17 +210,13 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val end = spanned.getSpanEnd(span) if (start < 0 || end <= start) return@forEach - val chromeStart = spanned.slockInlineCodeChromeStart(start, end) - val chromeEnd = spanned.slockInlineCodeChromeEnd(chromeStart, end) - if (chromeEnd <= chromeStart) return@forEach - - val startLine = textLayout.getLineForOffset(chromeStart) - val endLine = textLayout.getLineForOffset((chromeEnd - 1).coerceAtLeast(chromeStart)) + val startLine = textLayout.getLineForOffset(start) + val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) for (line in startLine..endLine) { val lineStart = textLayout.getLineStart(line) val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) - val segmentStart = max(chromeStart, lineStart) - val segmentEnd = min(chromeEnd, lineVisibleEnd) + val segmentStart = max(start, lineStart) + val segmentEnd = min(end, lineVisibleEnd) if (segmentEnd <= segmentStart) continue val startX = @@ -237,16 +233,12 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } val segmentLeft = min(startX, endX) val segmentRight = max(startX, endX) - val left = if (segmentStart == chromeStart) { + val left = if (segmentStart == start) { segmentLeft + horizontalMargin } else { segmentLeft - horizontalPadding } - val right = if (segmentEnd == chromeEnd) { - segmentRight - horizontalMargin - } else { - segmentRight + horizontalPadding - } + val right = segmentRight + horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() @@ -312,25 +304,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { return getLineVisibleEnd(line) } - private fun CharSequence.slockInlineCodeChromeStart(start: Int, end: Int): Int { - var index = start - while (index < end && this[index].isSlockInlineCodeChromeBoundaryWhitespace()) { - index++ - } - return index - } - - private fun CharSequence.slockInlineCodeChromeEnd(start: Int, end: Int): Int { - var index = end - while (index > start && this[index - 1].isSlockInlineCodeChromeBoundaryWhitespace()) { - index-- - } - return index - } - - private fun Char.isSlockInlineCodeChromeBoundaryWhitespace(): Boolean = - isWhitespace() || this == '\u00A0' - internal fun setSelectionByCoordinate( x: Float, y: Float, From 20f9c2a414a9fc3355f798817baa491c488dc869 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 22:54:55 +0800 Subject: [PATCH 059/187] fix(android): reserve inline code trailing margin outside chrome Signed-off-by: artin --- .../android/expand/component/text/KRRichTextBuilder.kt | 8 +++++++- .../expand/component/text/KRRichTextViewDrawer.kt | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 43dc99a2b..92cbbac77 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -58,6 +58,8 @@ import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f +private const val SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO = + SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO /** * 富文本构造器 @@ -621,12 +623,16 @@ private class KRSlockInlineCodeAtomicTextSpan( } private fun endPadding(paint: Paint): Float { - return if (padEnd) edgePadding(paint) else 0f + return if (padEnd) edgePadding(paint) + externalMarginAdvance(paint) else 0f } private fun edgePadding(paint: Paint): Float { return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) } + + private fun externalMarginAdvance(paint: Paint): Float { + return paint.textSize * SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO + } } /** diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 7c2d38bfe..980ec2103 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -39,6 +39,8 @@ private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 2f / 15f +private const val SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO = + SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f @@ -200,6 +202,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO + val externalMarginAdvance = paint.textSize * SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics @@ -238,7 +241,12 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = segmentRight + horizontalPadding + val right = if (segmentEnd == end) { + // The final atom reserves this extra advance for outside margin only. + segmentRight + horizontalPadding - externalMarginAdvance + } else { + segmentRight + horizontalPadding + } if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From 8b9623e77f0fa89f0b659d8f37b918c870b79293 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 23:03:19 +0800 Subject: [PATCH 060/187] Revert "fix(android): reserve inline code trailing margin outside chrome" This reverts commit 20f9c2a414a9fc3355f798817baa491c488dc869. --- .../android/expand/component/text/KRRichTextBuilder.kt | 8 +------- .../expand/component/text/KRRichTextViewDrawer.kt | 10 +--------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 92cbbac77..43dc99a2b 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -58,8 +58,6 @@ import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f -private const val SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO = - SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO /** * 富文本构造器 @@ -623,16 +621,12 @@ private class KRSlockInlineCodeAtomicTextSpan( } private fun endPadding(paint: Paint): Float { - return if (padEnd) edgePadding(paint) + externalMarginAdvance(paint) else 0f + return if (padEnd) edgePadding(paint) else 0f } private fun edgePadding(paint: Paint): Float { return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) } - - private fun externalMarginAdvance(paint: Paint): Float { - return paint.textSize * SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO - } } /** diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 980ec2103..7c2d38bfe 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -39,8 +39,6 @@ private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 2f / 15f -private const val SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO = - SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO + SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f @@ -202,7 +200,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val paint = textLayout.paint val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO - val externalMarginAdvance = paint.textSize * SLOCK_INLINE_CODE_EXTERNAL_MARGIN_ADVANCE_RATIO val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO val fontMetrics = paint.fontMetrics @@ -241,12 +238,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = if (segmentEnd == end) { - // The final atom reserves this extra advance for outside margin only. - segmentRight + horizontalPadding - externalMarginAdvance - } else { - segmentRight + horizontalPadding - } + val right = segmentRight + horizontalPadding if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From 1b14d8d4f32603888ffc4d26b8ec22a571525a57 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 23:15:02 +0800 Subject: [PATCH 061/187] fix(android): measure inline code trailing margin Signed-off-by: artin --- .../foundation/text/KuiklyTextExtension.kt | 11 +++++++ .../component/text/KRRichTextBuilder.kt | 31 +++++++++++++++++++ .../tencent/kuikly/core/views/RichTextView.kt | 5 +++ .../com/tencent/kuikly/core/views/TextView.kt | 1 + 4 files changed, 48 insertions(+) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 4e0109983..a5410dd93 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -54,6 +54,8 @@ import com.tencent.kuikly.core.views.TextConst import com.tencent.kuikly.core.views.TextSpan private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "raft.build.markdown.inlineCode" +private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_ANNOTATION_TAG = + "raft.build.markdown.inlineCodeTrailingMargin" private const val SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG = "raft.build.markdown.tagChrome" // Returns platform-specific default font size @@ -345,6 +347,12 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.start) positions.add(range.end) } + val slockInlineCodeTrailingMarginAnnotations = + annoText.getStringAnnotations(SLOCK_INLINE_CODE_TRAILING_MARGIN_ANNOTATION_TAG, 0, annoText.length) + slockInlineCodeTrailingMarginAnnotations.forEach { range -> + positions.add(range.start) + positions.add(range.end) + } val slockMarkdownTagChromeAnnotations = annoText.getStringAnnotations(SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG, 0, annoText.length) slockMarkdownTagChromeAnnotations.forEach { range -> @@ -401,6 +409,9 @@ internal fun RichTextAttr.applyAnnotatedString( if (slockInlineCodeAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCode() } + if (slockInlineCodeTrailingMarginAnnotations.any { range -> start >= range.start && end <= range.end }) { + slockInlineCodeTrailingMargin() + } slockMarkdownTagChromeAnnotations .firstOrNull { range -> start >= range.start && end <= range.end } ?.item diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 43dc99a2b..1b78b1fe3 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -58,6 +58,8 @@ import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f +private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = + SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO /** * 富文本构造器 @@ -236,6 +238,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.slockInlineCode) { textSpans.add(KRSlockInlineCodeSpan()) } + if (spanProps.slockInlineCodeTrailingMargin) { + textSpans.add(KRSlockInlineCodeTrailingMarginSpan()) + } spanProps.slockMarkdownTagChrome?.let { kind -> textSpans.add(KRSlockMarkdownTagSpan(kind)) } @@ -292,6 +297,7 @@ class TextSpanProps( val backgroundImage: String val backgroundColor: Int val slockInlineCode: Boolean + val slockInlineCodeTrailingMargin: Boolean val slockMarkdownTagChrome: String? var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -360,6 +366,8 @@ class TextSpanProps( ?: Color.TRANSPARENT slockInlineCode = spanValue.optInt(TextConst.SLOCK_INLINE_CODE, 0) == 1 || spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE, false) + slockInlineCodeTrailingMargin = spanValue.optInt(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, 0) == 1 || + spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, false) slockMarkdownTagChrome = spanValue.optString(TextConst.SLOCK_MARKDOWN_TAG_CHROME, "") .takeIf { it.isNotEmpty() } @@ -399,6 +407,29 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan class KRSlockMarkdownTagSpan(val kind: String) +private class KRSlockInlineCodeTrailingMarginSpan : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int = ceil((paint.textSize * SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO).toDouble()).toInt() + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) = Unit +} + private class KRCustomUnderlineSpan( private val color: Int?, private val thickness: Float?, diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index 3067a14d3..0421690c1 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -442,6 +442,11 @@ open class TextSpan : TextAttr(), ISpan { return this } + fun slockInlineCodeTrailingMargin(enabled: Boolean = true): TextSpan { + setProp(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, if (enabled) 1 else 0) + return this + } + fun slockMarkdownTagChrome(kind: String): TextSpan { if (kind.isNotBlank()) { setProp(TextConst.SLOCK_MARKDOWN_TAG_CHROME, kind) diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index 71eb52f0f..d9af17ee3 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -572,6 +572,7 @@ object TextConst { const val TEXT_POST_PROCESSOR = "textPostProcessor" const val TEXT_USE_DP_FONT_SIZE_DIM = "useDpFontSizeDim" const val SLOCK_INLINE_CODE = "slockInlineCode" + const val SLOCK_INLINE_CODE_TRAILING_MARGIN = "slockInlineCodeTrailingMargin" const val SLOCK_MARKDOWN_TAG_CHROME = "slockMarkdownTagChrome" const val SHADOW_METHOD_IS_LINE_BREAK_MARGIN = "isLineBreakMargin" From ba0811e12a49bcc97193c36e1834409116571de6 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 23:28:29 +0800 Subject: [PATCH 062/187] fix(android): pad trailing inline code separators Signed-off-by: artin --- .../android/expand/component/text/KRRichTextBuilder.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 1b78b1fe3..b3a7e08a9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -570,7 +570,13 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In } textLength = index - textStart } - if (index > textStart) { + if (textLength == 0 && + rangeStart < index && + !hasSlockInlineCodeAtomAfter(index, end) + ) { + textLength = index - rangeStart + } + if (textLength > 0) { val padStart = firstAtom val padEnd = !hasSlockInlineCodeAtomAfter(index, end) setSpan( From 7ef898179f7f3c1358a728908b19397c0d1f4254 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 23:41:29 +0800 Subject: [PATCH 063/187] fix(android): narrow inline code trailing margin Signed-off-by: artin --- .../render/android/expand/component/text/KRRichTextBuilder.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index b3a7e08a9..d23a44ba9 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -58,8 +58,7 @@ import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f -private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = - SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO +private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO /** * 富文本构造器 From 10640bf565a4330e640ab754c3d239b82fe053f1 Mon Sep 17 00:00:00 2001 From: artin Date: Mon, 6 Jul 2026 23:47:07 +0800 Subject: [PATCH 064/187] fix(android): soften inline code trailing margin Signed-off-by: artin --- .../render/android/expand/component/text/KRRichTextBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index d23a44ba9..65c02ef9b 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -58,7 +58,7 @@ import kotlin.math.max private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f -private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO +private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = 1f / 15f /** * 富文本构造器 From f6864c23ebc8d1f3bb6b3b02d4f1f60b50568f0f Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Tue, 7 Jul 2026 13:08:08 +0800 Subject: [PATCH 065/187] fix(compose): stop idle scroll boundary feedback (#4) Signed-off-by: artin --- .../tencent/kuikly/compose/scroller/ContentSizeExtensions.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 5668734fa..4c1e96cfd 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -319,6 +319,10 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea return } + if (isScrolling && kuiklyInfo.scrollView?.isDragging != true) { + return + } + KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.tryExpandStartSize++ } val density = kuiklyInfo.getDensity() From d65f724686811d7b9a7b8851093cac94f0f83194 Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Tue, 7 Jul 2026 09:18:23 +0000 Subject: [PATCH 066/187] fix(android): mirror inline-code trailing border (re-land on current tip) The inline-code chrome's trailing border was back to segmentRight + horizontalPadding (border ~10/15 past the glyphs, the "right side too big" regression). The mirror fix from #146/6fa8a025 was dropped when the mobile gitlink was bumped to this lineage (7ef89817 -> 10640bf -> ...) for the pager/scroll fixes, since 6fa8a025 was a sibling branch off 7ef89817 and never landed on the canonical branch. Re-apply on the current tip: final segment draws segmentRight - horizontalMargin, mirroring the leading edge. Co-Authored-By: Claude Opus 4.8 Signed-off-by: CC-Wow2 --- .../expand/component/text/KRRichTextViewDrawer.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 7c2d38bfe..386a27b8d 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -238,7 +238,17 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } else { segmentLeft - horizontalPadding } - val right = segmentRight + horizontalPadding + val right = if (segmentEnd == end) { + // Mirror the leading edge. The final atom reserves edgePadding + // (padding + margin) after its glyphs, so pull the border IN by + // horizontalMargin to land it exactly horizontalPadding past the + // last glyph — same inner padding as the start side — instead of + // pushing OUT by horizontalPadding (which put the border ~10/15 + // past the glyphs, the "right side too big" regression, #394/#54). + segmentRight - horizontalMargin + } else { + segmentRight + horizontalPadding + } if (right <= left) continue val baseline = textLayout.getLineBaseline(line).toFloat() From dd7759ad74e49974aff1e6026d81478a0a5a92c6 Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Tue, 7 Jul 2026 10:12:07 +0000 Subject: [PATCH 067/187] fix(android): wrap over-long inline-code instead of overflowing (#58) A long inline-code run with no whitespace/separator (e.g. `realtimeMessageUpdatedAppliesReactionPayload`) became one atomic ReplacementSpan, so it couldn't line-break and overflowed/clipped off the right edge. Once a run passes a threshold, end the atom at a camelCase boundary (natural for identifiers) or a hard char cap, giving the layout break points so long tokens wrap. Short runs stay a single atom (unchanged); continuation atoms render seamlessly, so a broken run looks identical on one line and only wraps when it must. Co-Authored-By: Claude Opus 4.8 Signed-off-by: CC-Wow2 --- .../component/text/KRRichTextBuilder.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 65c02ef9b..b8f813b49 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -60,6 +60,17 @@ private const val SLOCK_INLINE_CODE_EDGE_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = 1f / 15f +// Each inline-code atom is an atomic ReplacementSpan, so a long run with no +// whitespace/separator (e.g. `realtimeMessageUpdatedAppliesReactionPayload`) +// can't line-break and overflows/clips off the right edge (#58). Once a run +// grows past the threshold, break it into more atoms so the layout can wrap — +// preferring a camelCase boundary (natural for identifiers), else a hard char +// cap for runs with none (hashes/urls). Short runs stay a single atom (no +// change / no extra stroke padding). Continuation atoms render seamlessly, so a +// broken run looks identical on one line and only wraps when it must. +private const val SLOCK_INLINE_CODE_ATOM_BREAK_THRESHOLD = 12 +private const val SLOCK_INLINE_CODE_ATOM_MAX_TEXT_LEN = 20 + /** * 富文本构造器 */ @@ -545,6 +556,16 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && !this[index].isSlockInlineCodeBreakSeparator() ) { + // #58: once a run passes the threshold, end the atom at a camelCase + // boundary (or the hard cap) so an over-long token can wrap instead + // of overflowing. Short runs never trip this and stay one atom. + if (index > textStart && index - textStart >= SLOCK_INLINE_CODE_ATOM_BREAK_THRESHOLD) { + val prev = this[index - 1] + val camelBoundary = (prev.isLowerCase() || prev.isDigit()) && this[index].isUpperCase() + if (camelBoundary || index - textStart >= SLOCK_INLINE_CODE_ATOM_MAX_TEXT_LEN) { + break + } + } index++ } var textLength = index - textStart From 6a8ab4708fc33ec95acf952b3b0e638bcf73eefb Mon Sep 17 00:00:00 2001 From: CC-Wow2 Date: Tue, 7 Jul 2026 11:05:39 +0000 Subject: [PATCH 068/187] fix(android): char-level auto-wrap for long inline-code (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per artin: this should be standard layout-engine behaviour — a long word char-wraps at the line edge. Replace the camelCase/cap heuristic with true per-character wrapping: a run longer than the threshold is emitted as per-character atoms so the layout breaks at any character. Those atoms are seamless (no per-atom stroke padding), and the border is drawn per line-segment by the drawer, so the run looks identical on one line and only wraps when it overflows. Co-Authored-By: Claude Opus 4.8 Signed-off-by: CC-Wow2 --- .../component/text/KRRichTextBuilder.kt | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index b8f813b49..4cdf9e887 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -62,14 +62,13 @@ private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = 1f / 15f // Each inline-code atom is an atomic ReplacementSpan, so a long run with no // whitespace/separator (e.g. `realtimeMessageUpdatedAppliesReactionPayload`) -// can't line-break and overflows/clips off the right edge (#58). Once a run -// grows past the threshold, break it into more atoms so the layout can wrap — -// preferring a camelCase boundary (natural for identifiers), else a hard char -// cap for runs with none (hashes/urls). Short runs stay a single atom (no -// change / no extra stroke padding). Continuation atoms render seamlessly, so a -// broken run looks identical on one line and only wraps when it must. -private const val SLOCK_INLINE_CODE_ATOM_BREAK_THRESHOLD = 12 -private const val SLOCK_INLINE_CODE_ATOM_MAX_TEXT_LEN = 20 +// can't line-break and overflows/clips off the right edge (#58). A standard +// layout engine char-wraps a long word; to get that, a run longer than this +// threshold is emitted as per-character atoms so the layout can break at any +// character. Those atoms are marked seamless (no per-atom stroke padding) so the +// run looks identical on one line and only wraps when it must. Short runs stay a +// single atom, unchanged. +private const val SLOCK_INLINE_CODE_LONG_RUN_THRESHOLD = 16 /** * 富文本构造器 @@ -556,18 +555,27 @@ private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: In !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && !this[index].isSlockInlineCodeBreakSeparator() ) { - // #58: once a run passes the threshold, end the atom at a camelCase - // boundary (or the hard cap) so an over-long token can wrap instead - // of overflowing. Short runs never trip this and stay one atom. - if (index > textStart && index - textStart >= SLOCK_INLINE_CODE_ATOM_BREAK_THRESHOLD) { - val prev = this[index - 1] - val camelBoundary = (prev.isLowerCase() || prev.isDigit()) && this[index].isUpperCase() - if (camelBoundary || index - textStart >= SLOCK_INLINE_CODE_ATOM_MAX_TEXT_LEN) { - break - } - } index++ } + // #58: a long no-break run would be one atomic ReplacementSpan and would + // overflow the line. Emit it as per-character seamless atoms so the + // layout char-wraps it, the way a standard engine wraps a long word. + if (index - textStart > SLOCK_INLINE_CODE_LONG_RUN_THRESHOLD) { + var charIndex = rangeStart + while (charIndex < index) { + val padStart = firstAtom && charIndex == rangeStart + val padEnd = charIndex == index - 1 && !hasSlockInlineCodeAtomAfter(index, end) + setSpan( + KRSlockInlineCodeAtomicTextSpan(padStart, padEnd, seamless = true), + charIndex, + charIndex + 1, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + firstAtom = false + charIndex++ + } + continue + } var textLength = index - textStart while (textLength in 1..2 && index < end && @@ -640,7 +648,11 @@ private fun CharSequence.hasSlockInlineCodeAtomAfter(start: Int, end: Int): Bool private class KRSlockInlineCodeAtomicTextSpan( private val padStart: Boolean, - private val padEnd: Boolean + private val padEnd: Boolean, + // #58: per-character atoms of a char-wrapped long run. They must not each add + // stroke padding, or the run would spread out; the chrome/border is drawn + // per line-segment by the drawer, so interior atoms need none. + private val seamless: Boolean = false ) : ReplacementSpan() { override fun getSize( @@ -653,7 +665,7 @@ private class KRSlockInlineCodeAtomicTextSpan( 0 } else { val textWidth = paint.measureText(text, start, end) - val strokePadding = max(1f, paint.strokeWidth * 2f) + val strokePadding = if (seamless) 0f else max(1f, paint.strokeWidth * 2f) ceil((textWidth + strokePadding + startPadding(paint) + endPadding(paint)).toDouble()).toInt() } From acc232dad590a19bc4cc73c73108ced8f37eeafc Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 7 Jul 2026 11:44:48 +0000 Subject: [PATCH 069/187] fix(android): align inline-code fill to react brand yellow D440 Inline-code chrome fill was 0x66FFD84D, a drift off the app's brand yellow. React renders inline code as bg-soft-signal/40 (soft-signal == brutal-yellow == #FFD440); the app's own tag/self-mention fills are already 0x*FFD440. Only inline-code was off. Aligns to the shared inline-code token single source so react/android (and forthcoming ohos/ios chip chrome) all use one yellow. --- .../android/expand/component/text/KRRichTextViewDrawer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 386a27b8d..99b4257a3 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -35,7 +35,11 @@ import kotlin.math.max import kotlin.math.min private const val INVALID_OFFSET = -1 -private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD84D +// react baseline: MarkdownContent inline `code` = bg-soft-signal/40, and +// soft-signal == brutal-yellow == #FFD440 (web index.css). 0x66 == 40% alpha. +// Was 0x66FFD84D (D84D) — a drift off the brand yellow that mismatched both +// react and the app's own tag/self-mention fills (D440, below). Single source: D440. +private const val SLOCK_INLINE_CODE_FILL_COLOR = 0x66FFD440 private const val SLOCK_INLINE_CODE_BORDER_COLOR = 0xFF000000.toInt() private const val SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO = 4f / 15f private const val SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO = 2f / 15f From 661e6a3a52d1deede6500094de31d20c1c83fd0c Mon Sep 17 00:00:00 2001 From: Codex-Kuikly-KMP Date: Tue, 7 Jul 2026 21:24:37 +0800 Subject: [PATCH 070/187] revert(compose): drop lazy scroll white-screen patch set Signed-off-by: Codex-Kuikly-KMP --- ...ose-all-sample-ohos-scroll-white-screen.md | 266 ------------------ .../compose/foundation/lazy/LazyListState.kt | 3 - .../compose/gestures/KuiklyScrollInfo.kt | 42 --- .../compose/gestures/KuiklyScrollTrace.kt | 121 -------- .../compose/layout/SubcomposeLayoutEx.kt | 11 +- .../compose/scroller/ContentSizeExtensions.kt | 60 +--- .../scroller/ScrollableStateExtensions.kt | 10 - .../kuikly/compose/ui/layout/Placeable.kt | 2 - .../compose/ui/layout/SubcomposeLayout.kt | 101 ++----- .../tencent/kuikly/compose/ui/node/KNode.kt | 9 - .../kuikly/compose/ui/node/RootNodeOwner.kt | 19 +- .../components/richtext/KRRichTextView.cpp | 20 +- .../components/scroller/KRScrollerView.cpp | 91 +----- .../components/scroller/KRScrollerView.h | 14 +- .../pages/compose/BugReproCanScrollForward.kt | 138 --------- .../demo/pages/compose/ComposeAllSample.kt | 37 +-- .../main/ets/entryability/EntryAbility.ets | 13 - ohosApp/entry/src/main/ets/pages/Index.ets | 25 +- 18 files changed, 54 insertions(+), 928 deletions(-) delete mode 100644 BugFix/compose-all-sample-ohos-scroll-white-screen.md delete mode 100644 compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt delete mode 100644 demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt diff --git a/BugFix/compose-all-sample-ohos-scroll-white-screen.md b/BugFix/compose-all-sample-ohos-scroll-white-screen.md deleted file mode 100644 index ad368ceee..000000000 --- a/BugFix/compose-all-sample-ohos-scroll-white-screen.md +++ /dev/null @@ -1,266 +0,0 @@ -# ComposeAllSample 鸿蒙滑动白屏优化总结 - -> 场景:鸿蒙设备上进入 `ComposeAllSample`(Demo案例-Compose语法),快速/慢速滑动 LazyColumn 时出现视口内大块空白(白屏)。 -> -> 状态:**框架层优化已验收**;`ComposeAllSample.kt` **保持 main 原版未改**,流畅度仍明显改善(说明收益主要来自框架,而非 Demo 页减负)。 - ---- - -## 1. 问题本质 - -Kuikly Compose 的 `LazyColumn` **不是** ArkUI 原生 List,而是 **双引擎滚动**: - -``` -ArkUI ScrollerView 位移 - → Native onScroll 桥接到 Kotlin - → SubcomposeLayout 同步 composeOffset - → LazyListState.kuiklyOnScroll → remeasure / subcompose 新 item - → 创建/更新原生 DivView + Text(KRRichTextView 绘制) -``` - -白屏不是 crash,而是 **滚动链路过长 + 回调过频**,新 item 来不及绘制,视口短暂露出背景色 `#F5F5F5`。 - -`KuiklyScrollTrace` 日志证实:瓶颈在 **每次 onScroll 都触发 calcSize / expand / 桥接**(一次 fling 可达 300+ 次 Kuikly 业务回调),而非 hilog 打印本身。 - ---- - -## 2. 修改项生效度排名 - -按 **对流畅度 / 白屏的实测贡献** 排序(★★★★★ 最高)。排名依据:`KuiklyScrollTrace` 前后对比 + 恢复 `ComposeAllSample.kt` 后仍可流畅的交叉验证。 - -| 排名 | 生效度 | 修改项 | 文件 | 日志 / 现象依据 | -|:---:|--------|--------|------|-----------------| -| **#1** | ★★★★★ | **expand 空转去除**:`tryExpandStartSize` 仅在双端 offset 真正不同步时执行 | `ContentSizeExtensions.kt` | `expand` 总量 **944 → 0**;此前 `kuiklyScroll=0` 时仍 expand 36 次/手势 | -| **#2** | ★★★★★ | **calc/expand 与 `kuiklyOnScroll` 绑定**:只有 LazyList 真实滚动后才 calc + expand | `SubcomposeLayout.kt` | 快滑 `calcSize` **170 → 38**(`kuiklyScroll=14~37`) | -| **#3** | ★★★★☆ | **contentSize 去重**:`lastAppliedContentSize` 避免重复 `setFrame` | `KuiklyScrollInfo.kt` | `dedup` 数百次 vs `setFrame` 个位数;抑制原生 contentView relayout 风暴 | -| **#4** | ★★★★☆ | **RichText 排版就绪时继续绘制**:主线程任务中 typography 已就绪则不 Skip | `KRRichTextView.cpp` | `OnForegroundDraw Skip` **恒为 0**;直接消除新 item 文字白块 | -| **#5** | ★★★★☆ | **滚动中 calc 节流**:`calculateAndUpdateContentSizeIfNeeded` 仅近底 / 未知真实高度时更新 | `ContentSizeExtensions.kt` | 长滑中间段不再每帧读 frame;`calc/scroll` **1.57x → 1.13x** | -| **#6** | ★★★☆☆ | **Fling 态 Native 量化 2vp**:快滑加大位移阈值 | `KRScrollerView.cpp` | `fireToBridge` **173 → 90**(同场景快滑);`fireSkipped` 提升至 ~23–35% | -| **#7** | ★★★☆☆ | **Compose sub-pixel 过滤**:`< 0.5px` 位移累积,不驱动 remeasure | `SubcomposeLayout.kt` | 与 LazyListState 对齐;挡鸿蒙高频小数 onScroll | -| **#8** | ★★★☆☆ | **触底边界 defer**:`pendingBottomExpand` 标记,scrollEnd 统一扩容 | `SubcomposeLayout.kt` + `KuiklyScrollInfo.kt` | 消除 `toButtomDelta<=0` 每帧 calc(earlyRet 手势中 calc 虚高主因) | -| **#9** | ★★★☆☆ | **scrollEnd 统一收尾**:`finalizeNativeScrollSync` 一次 calc + offset 校正 | `SubcomposeLayout.kt` + `ContentSizeExtensions.kt` | 保证手势结束双端 offset / contentSize 最终一致 | -| **#10** | ★★☆☆☆ | **慢拖 Native 量化 0.5vp** + scrollStop `force` flush | `KRScrollerView.cpp` | 慢滑仍 ~1:1 桥接,但消除 sub-pixel 噪声;stop 时补齐尾差 | -| **#11** | ★★☆☆☆ | **语义树 debounce + 无障碍关闭时跳过** | `RootNodeOwner.kt` | 滚动中少遍历语义树;Demo 默认 `debugUIInspector=true` 时收益有限 | -| **#12** | ★☆☆☆☆ | **OHOS expand delay 缩短**(25→16ms,settle 150→80ms) | `ContentSizeExtensions.kt` | 停手后空白窗口略缩短;难单独量化 | -| — | (诊断) | `KuiklyScrollTrace` 分层计数 | `KuiklyScrollTrace.kt` | 非性能优化;`ENABLED=false` 默认关闭 | -| — | (未采用) | **ComposeAllSample 页面减负** | `ComposeAllSample.kt` | 见 §3;**未合入**,恢复 main 后仍流畅 | - -### 生效度分级说明 - -| 等级 | 含义 | -|------|------| -| ★★★★★ | 日志有数量级变化,或直接导致白块消失;**必须合入** | -| ★★★★☆ | 显著减少重操作 / 原生 relayout;**强烈建议合入** | -| ★★★☆☆ | 明显减少回调或边界 case 浪费;**建议合入** | -| ★★☆☆☆ | 有收益但难单独量化,或仅特定场景;**可合入** | -| ★☆☆☆☆ | 边际优化;**可选** | -| 未采用 | 业务页可选实践,**非框架必需**(本次验证已排除) | - ---- - -## 3. 已合入修改详情(按排名) - -### #1–#2 滚动同步:「只在真正滚动时做重活」 - -**优化前**:每次 Native `onScroll`(~60fps)都执行 `calculateAndUpdateContentSize` + `tryExpandStartSize` + 可能 `kuiklyOnScroll`。 - -**优化后**: - -``` -Native onScroll - ├─ [L1] 位移量化(0.5vp / fling 2vp) → 减桥接 - ├─ [L2] Compose sub-pixel 过滤(< 0.5px) → 减 remeasure - ├─ [L3] earlyReturn(顶边界 / ignoreOffset) → 不驱动 LazyList - └─ [L4] kuiklyOnScroll 成功后 - ├─ calculateAndUpdateContentSizeIfNeeded() - └─ tryExpandStartSize()(#1 条件守卫) -scrollEnd → finalizeNativeScrollSync() → 一次收尾 -``` - -```kotlin -// SubcomposeLayout.kt — 仅真实滚动后同步 -scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) -scrollableState.calculateAndUpdateContentSizeIfNeeded() -scrollableState.tryExpandStartSize(offset, true) -``` - -```kotlin -// ContentSizeExtensions.kt — expand 空转去除(#1) -val needsTopExpand = offset <= 0 && !atTopSync && kuiklyInfo.offsetDirty -val needsScrollViewPullBack = offset > 0 && atTopSync -if (!needsTopExpand && !needsScrollViewPullBack) return -``` - ---- - -### #3 contentSize 去重(`KuiklyScrollInfo.kt`) - -```kotlin -private var lastAppliedContentSize: Int = -1 - -fun updateContentSizeToRender() { - if (currentContentSize == lastAppliedContentSize) return - lastAppliedContentSize = currentContentSize - scrollView?.contentView?.setFrameToRenderView(createContentFrame()) -} -``` - ---- - -### #4 RichText 绘制(`KRRichTextView.cpp`) - -```cpp -if (rootView->IsPerformMainTasking()) { - if (richTextShadow == nullptr || richTextShadow->MainThreadTypographyHandle() == nullptr) { - // 排版未就绪 → 下一帧 markDirty - return; - } - // typography 就绪 → 继续绘制,不 Skip -} -``` - ---- - -### #5–#9 calc 节流与 scrollEnd 收尾(`ContentSizeExtensions.kt`) - -```kotlin -internal fun ScrollableState.calculateAndUpdateContentSizeIfNeeded(force: Boolean = false) { - if (force || kuiklyInfo.nearScrollBottom() || kuiklyInfo.realContentSize == null) { - calculateAndUpdateContentSize() - } -} - -internal fun ScrollableState.finalizeNativeScrollSync(offset: Int) { - calculateAndUpdateContentSize() - if (kuiklyInfo.pendingBottomExpand) { - kuiklyInfo.pendingBottomExpand = false - } - tryExpandStartSize(offset, isScrolling = false) -} -``` - -```kotlin -// SubcomposeLayout.kt — 触底 defer(#8) -if (toButtomDelta.toInt() <= 0) { - kuiklyInfo.pendingBottomExpand = true - return@scroll -} -``` - ---- - -### #6–#10 Native 滚动量化(`KRScrollerView.cpp`) - -```cpp -constexpr float kMinScrollOffsetDelta = 0.5f; -constexpr float kFlingScrollOffsetDelta = 2.0f; -const float minDelta = (current_scroll_state_ == ARKUI_SCROLL_STATE_FLING) - ? kFlingScrollOffsetDelta - : kMinScrollOffsetDelta; -``` - -`OnScrollStop` 时 `FireOnScrollEvent(event, true)` 强制 flush 最终 offset。 - ---- - -### #11 语义树(`RootNodeOwner.kt`) - -```kotlin -override fun onSemanticsChange() { - if (!isSemanticsRunnnng) return - semanticsDebounceJob?.cancel() - semanticsDebounceJob = semanticsCoroutineScope.launch { - delay(100) - semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) - } -} -``` - ---- - -## 4. 未采用的页面层优化(可选参考) - -以下改动曾验证有效,但 **`ComposeAllSample.kt` 已恢复 `main` 原版**,未纳入最终合入范围。业务列表可参考,**非白屏根因修复**。 - -| 项 | 改法 | 预估生效度 | 说明 | -|----|------|-----------|------| -| 关 UI Inspector | `debugUIInspector()` 默认 `false` | ★★☆☆☆ | 减调试 overlay;Demo 当前仍为 `true` | -| stable key | `items(..., key = { it.pageName })` | ★★☆☆☆ | 减 slot 重建 | -| 去 Card shadow | `Row + background` 替代 `Card` | ★★☆☆☆ | 减离屏阴影 | -| 固定 item 高度 | `.height(72.dp)` | ★★★☆☆ | 提升 `noRemeasure` 比例;对慢滑白屏有帮助 | - ---- - -## 5. 日志验收数据 - -诊断:`KuiklyScrollTrace`(`ENABLED=true`,`hilog | grep KuiklyScrollTrace`) - -### 5.1 框架优化前后(ComposeAllSample 有页面改动时期) - -| 指标 | 优化前(18 次手势) | 优化后(15 次手势) | -|------|---------------------|---------------------| -| expand 总量 | **944** | **0** | -| calc / kuiklyScroll | 1.57x | 1.13x | - -### 5.2 典型快速 fling - -| 指标 | 优化前 | 框架优化后 | -|------|--------|------------| -| fireToBridge | 173 | 90 | -| kuiklyScroll | 14 | 37 | -| calcSize | **170** | **38** | -| expand | **170** | **0** | - -### 5.3 恒成立项 - -- `OnForegroundDraw Skip`:**0** -- `setFrame` 极少,`dedup` 占绝大多数 -- 恢复 `ComposeAllSample.kt` 后:**仍流畅** → 排名 #1–#11 框架改动可独立生效 - ---- - -## 6. 已合入文件清单 - -``` -compose/.../SubcomposeLayout.kt # #2 #7 #8 #9 -compose/.../ContentSizeExtensions.kt # #1 #5 #9 #12 -compose/.../KuiklyScrollInfo.kt # #3 #8 -compose/.../RootNodeOwner.kt # #11 -compose/.../KuiklyScrollTrace.kt # 诊断(默认关) -core-render-ohos/.../KRScrollerView.cpp/.h # #6 #10 -core-render-ohos/.../KRRichTextView.cpp # #4 -``` - -**未修改**:`demo/.../ComposeAllSample.kt`(保持 `main`) - ---- - -## 7. 可复用经验 - -1. **先查「同步频率」再查「单帧绘制」**:双引擎列表的白屏多为回调风暴,不是 GPU 慢。 -2. **用分层计数定位空转**:`fireToBridge` / `kuiklyScroll` / `calc` / `expand` / `remeasure` 分开统计。 -3. **去重 > 节流 > 延后**:`lastAppliedContentSize`(#3)成本低收益高;calc 绑定滚动(#2)次之;scrollEnd 收尾(#9)保底一致性。 -4. **框架优化可独立于业务页**:本次恢复 Demo 原版后仍流畅,说明 #1–#11 是通用收益。 -5. **业务页优化(§4)是锦上添花**:固定高度、stable key 对慢滑 remeasure 仍有价值,但不替代框架改动。 - ---- - -## 8. 后续可选 - -| 优先级 | 方向 | 关联排名 | -|--------|------|----------| -| 中 | 慢滑 remeasure 根因(item 高度稳定性) | 对标 §4 固定高度 | -| 中 | 语义同步全局开关(列表页默认关) | #11 增强 | -| 低 | iOS / Android 对齐 fling 2vp 策略 | #6 跨端 | -| 低 | MR 拆分:仅框架层一个 PR | — | - ---- - -## 9. 复现与验证 - -**进入 ComposeAllSample(鸿蒙)**: - -1. 冷启动 App → 「Kuikly页面路由」 -2. 点击 **「Demo案例-Compose语法」**(须 `router.pushUrl`,勿用 `aa start --ps pageName` 冷启动) - -**验收**:S1 快速 fling ×3、S2 匀速滑、S3 边界来回、S4 静止后 fling;视口无大块空白,`OnForegroundDraw Skip = 0`。 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt index 20b163597..df2777532 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListState.kt @@ -57,7 +57,6 @@ import com.tencent.kuikly.compose.ui.unit.dp import com.tencent.kuikly.compose.ui.util.fastFirstOrNull import com.tencent.kuikly.compose.ui.util.fastRoundToInt import com.tencent.kuikly.compose.ui.util.fastSumBy -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.scroller.tryExpandStartSizeNoScroll import com.tencent.kuikly.compose.profiler.RecompositionProfiler @@ -416,7 +415,6 @@ class LazyListState ) } if (scrolledWithoutRemeasure) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.lazyScrollWithoutRemeasure++ } applyMeasureResult( result = layoutInfo, isLookingAhead = hasLookaheadPassOccurred, @@ -425,7 +423,6 @@ class LazyListState // we don't need to remeasure, so we only trigger re-placement: placementScopeInvalidator.invalidateScope() } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.lazyRemeasure++ } remeasurement?.forceRemeasure() } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 660295160..837d857be 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -84,9 +84,6 @@ class KuiklyScrollInfo { */ var realContentSize: Int? = null - /** 上次 calc 时读到的 native contentView 主轴尺寸(px),用于滚动中节流 */ - internal var lastSyncedNativeContentMainAxisPx: Int = -1 - /** * Whether the offset has deviation */ @@ -174,11 +171,6 @@ class KuiklyScrollInfo { */ var cachedTotalItems: Int = 0 - /** - * 滚动中触及底部边界(toButtomDelta<=0)时置位,scrollEnd 时统一扩容 contentSize。 - */ - var pendingBottomExpand: Boolean = false - /** * When true, [tryExpandStartSize] is skipped. Used by [ScrollableTabRow] whose content * size is already exact via [ScrollState.maxValue] + viewport. @@ -200,28 +192,7 @@ class KuiklyScrollInfo { /** * Update content size to render view */ - private var lastAppliedContentSize: Int = -1 - /** 与 contentSize 一并参与去重,避免 Android 上 ScrollView 宽度晚于首次 setFrame 时 contentView 宽度卡在 0 */ - private var lastAppliedViewportCrossSize: Float = -1f - fun updateContentSizeToRender() { - val viewportCrossSize = if (isVertical()) { - scrollView?.renderView?.currentFrame?.width ?: 0f - } else { - scrollView?.renderView?.currentFrame?.height ?: 0f - } - if (viewportCrossSize <= 0f) { - return - } - if (currentContentSize == lastAppliedContentSize && - viewportCrossSize == lastAppliedViewportCrossSize - ) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeDeduped++ } - return - } - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentSizeToRender++ } - lastAppliedContentSize = currentContentSize - lastAppliedViewportCrossSize = viewportCrossSize val frame = createContentFrame() scrollView?.contentView?.setFrameToRenderView(frame) } @@ -251,19 +222,6 @@ class KuiklyScrollInfo { stickyItemKey = null cachedTotalItems = 0 pullToRefreshTopInsetPx = 0 - lastAppliedContentSize = -1 - lastAppliedViewportCrossSize = -1f - lastSyncedNativeContentMainAxisPx = -1 - pendingBottomExpand = false - } - - internal fun nativeContentMainAxisDp(): Float { - val scrollView = scrollView ?: return -1f - return if (orientation == Orientation.Vertical) { - scrollView.contentView?.renderView?.currentFrame?.height ?: -1f - } else { - scrollView.contentView?.renderView?.currentFrame?.width ?: -1f - } } /** diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt deleted file mode 100644 index 5ea51c74b..000000000 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollTrace.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Tencent is pleased to support the open source community by making KuiklyUI - * available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the License of KuiklyUI; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tencent.kuikly.compose.gestures - -/** - * 滚动链路诊断:统计一次手势内各层调用次数,在 scrollEnd 时汇总打印。 - * 过滤:hilog | grep KuiklyScrollTrace - * - * 验收时临时设 [ENABLED]=true;合入前保持 false。 - */ -internal object KuiklyScrollTrace { - /** 调试/验收时设为 true;发布前保持 false */ - const val ENABLED = false - - private const val TAG = "KuiklyScrollTrace" - - var composeScrollReceived = 0 - var composeDeltaFiltered = 0 - var composeEarlyReturn = 0 - var calculateContentSize = 0 - var kuiklyOnScroll = 0 - var tryExpandStartSize = 0 - var contentSizeToRender = 0 - var contentSizeDeduped = 0 - var lazyRemeasure = 0 - var lazyScrollWithoutRemeasure = 0 - var calcSizeSkipped = 0 - var kuiklyScrollNs = 0L - var calcSizeNs = 0L - - // scroll audit(极致性能验收指标) - var updateKuiklyViewFrameCalls = 0 - /** compute 之前的脏检查跳过(避免 viewPositionOf 坐标链 walk) */ - var framePreSkip = 0 - /** compute 之后 frame 未变跳过 */ - var frameSyncSkipped = 0 - /** placeSelf + delegate 同轮 placement 去重 */ - var framePlacementDedup = 0 - var frameComputeNs = 0L - var resetVisibleSkipped = 0 - var contentOffsetWrites = 0 - var contentOffsetSkipped = 0 - var isDraggingWrites = 0 - var isDraggingSkipped = 0 - var contentSizeStateWrites = 0 - var contentSizeStateSkipped = 0 - var coordAccessMark = 0 - var coordAccessRelayout = 0 - var placementCoordAccess = 0 - - inline fun ifEnabled(block: () -> Unit) { - if (ENABLED) block() - } - - fun reset() { - composeScrollReceived = 0 - composeDeltaFiltered = 0 - composeEarlyReturn = 0 - calculateContentSize = 0 - kuiklyOnScroll = 0 - tryExpandStartSize = 0 - contentSizeToRender = 0 - contentSizeDeduped = 0 - lazyRemeasure = 0 - lazyScrollWithoutRemeasure = 0 - calcSizeSkipped = 0 - kuiklyScrollNs = 0L - calcSizeNs = 0L - updateKuiklyViewFrameCalls = 0 - framePreSkip = 0 - frameSyncSkipped = 0 - framePlacementDedup = 0 - frameComputeNs = 0L - resetVisibleSkipped = 0 - contentOffsetWrites = 0 - contentOffsetSkipped = 0 - isDraggingWrites = 0 - isDraggingSkipped = 0 - contentSizeStateWrites = 0 - contentSizeStateSkipped = 0 - coordAccessMark = 0 - coordAccessRelayout = 0 - placementCoordAccess = 0 - } - - fun dumpSummary(phase: String) { - if (!ENABLED) return - val scrollMs = (kuiklyScrollNs / 1_000_000.0 * 10).toLong() / 10.0 - val calcMs = (calcSizeNs / 1_000_000.0 * 10).toLong() / 10.0 - val frameMs = (frameComputeNs / 1_000_000.0 * 10).toLong() / 10.0 - val remeasureRate = if (kuiklyOnScroll > 0) { - (lazyRemeasure * 1000 / kuiklyOnScroll) / 10.0 - } else 0.0 - println( - "[$TAG] $phase | " + - "composeIn=$composeScrollReceived filtered=$composeDeltaFiltered earlyRet=$composeEarlyReturn " + - "calcSize=$calculateContentSize skipped=$calcSizeSkipped kuiklyScroll=$kuiklyOnScroll expand=$tryExpandStartSize " + - "setFrame=$contentSizeToRender dedup=$contentSizeDeduped " + - "remeasure=$lazyRemeasure noRemeasure=$lazyScrollWithoutRemeasure remeasureRate=${remeasureRate}% " + - "scrollMs=$scrollMs calcMs=$calcMs frameMs=$frameMs | " + - "audit: frameCalls=$updateKuiklyViewFrameCalls preSkip=$framePreSkip postSkip=$frameSyncSkipped placeDedup=$framePlacementDedup resetSkip=$resetVisibleSkipped " + - "offW=$contentOffsetWrites offSkip=$contentOffsetSkipped " + - "dragW=$isDraggingWrites dragSkip=$isDraggingSkipped " + - "sizeW=$contentSizeStateWrites sizeSkip=$contentSizeStateSkipped " + - "coordMark=$coordAccessMark coordRelayout=$coordAccessRelayout placeCoord=$placementCoordAccess" - ) - } -} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt index 2de411374..a8101d428 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt @@ -22,7 +22,6 @@ import com.tencent.kuikly.compose.foundation.lazy.grid.LazyGridMeasureResult import com.tencent.kuikly.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridMeasureResult import com.tencent.kuikly.compose.foundation.pager.PagerMeasureResult import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.ui.layout.LayoutNodeSubcompositionsState import com.tencent.kuikly.compose.ui.layout.MeasureResult @@ -84,12 +83,12 @@ internal fun KNode<*>.hideOffsetScreenView() { internal fun KNode<*>.resetViewVisible() { when { isVirtual -> forEachChild { (it as? KNode<*>)?.resetViewVisible() } - viewVisible == null -> { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.resetVisibleSkipped++ } - } else -> { - view.getViewAttr().visibility(viewVisible!!) - viewVisible = null + // 恢复到原始的Visible属性 + viewVisible?.let { + view.getViewAttr().visibility(it) + viewVisible = null + } } } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 4c1e96cfd..14b81d5ca 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -15,7 +15,6 @@ package com.tencent.kuikly.compose.scroller -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.foundation.ScrollState import com.tencent.kuikly.compose.foundation.gestures.Orientation import com.tencent.kuikly.compose.foundation.gestures.ScrollableState @@ -75,7 +74,6 @@ internal fun ScrollableState.calculateContentSize(): Int { } internal fun ScrollableState.calculateAndUpdateContentSize() { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.calculateContentSize++ } // 更新当前的contentSize大小 val oldContentSize = kuiklyInfo.currentContentSize val newContentSize = calculateContentSize() @@ -96,49 +94,6 @@ internal fun ScrollableState.calculateAndUpdateContentSize() { kuiklyInfo.updateContentSizeToRender() } -/** - * 滚动过程中仅在接近底部或尚未得到真实 contentSize 时更新 native contentSize。 - * [force] 用于 scrollEnd 等必须同步的时机。 - */ -internal fun ScrollableState.calculateAndUpdateContentSizeIfNeeded(force: Boolean = false) { - if (force) { - calculateAndUpdateContentSize() - return - } - if (kuiklyInfo.nearScrollBottom()) { - calculateAndUpdateContentSize() - return - } - if (kuiklyInfo.realContentSize != null) { - return - } - val nativeDp = kuiklyInfo.nativeContentMainAxisDp() - if (nativeDp < 0f) { - calculateAndUpdateContentSize() - return - } - val nativePx = (nativeDp * kuiklyInfo.getDensity()).toInt() - if (nativePx != kuiklyInfo.lastSyncedNativeContentMainAxisPx) { - kuiklyInfo.lastSyncedNativeContentMainAxisPx = nativePx - calculateAndUpdateContentSize() - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.calcSizeSkipped++ } - } -} - -/** - * 一次手势结束后的 native 滚动同步:contentSize + offset 校正 + 底部扩容。 - */ -internal fun ScrollableState.finalizeNativeScrollSync(offset: Int) { - calculateAndUpdateContentSize() - if (kuiklyInfo.pendingBottomExpand) { - kuiklyInfo.pendingBottomExpand = false - } - if (!isNestedScrollConfigured()) { - tryExpandStartSize(offset, isScrolling = false) - } -} - internal fun PaddingValues.totalPadding(orientation: Orientation): Dp { return if (orientation == Orientation.Vertical) { calculateTopPadding() + calculateBottomPadding() @@ -308,7 +263,7 @@ internal fun ScrollableState.calculateBackExpandSize(offset: Int): Int? { * 尝试扩展起始大小 */ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolean) { - if (kuiklyInfo.scrollView == null || isNestedScrollConfigured()) return + if (kuiklyInfo.scrollView == null) return if (kuiklyInfo.skipExpandStartSize) return if (this is PagerState) return @@ -323,8 +278,6 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea return } - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.tryExpandStartSize++ } - val density = kuiklyInfo.getDensity() // scrollview 到顶了,但是compose没到顶 if (needsTopExpand) { @@ -347,7 +300,7 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea } kuiklyInfo.offsetDirty = true applyScrollViewOffsetDelta(delta) - } else if (needsScrollViewPullBack) { + } else if (offset > 0 && isComposeAtTopForScrollSync()) { // compose 到顶了,但是scrollview没到顶 applyScrollViewOffsetDelta(-offset) kuiklyInfo.offsetDirty = false @@ -359,16 +312,11 @@ internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = f kuiklyInfo.run { appleScrollViewOffsetJob?.cancel(ScrollViewOffsetAlignmentCancellation) appleScrollViewOffsetJob = scope?.launch { - val settleDelay = if (pageData?.isOhOs == true) 80 else 150 - delay(settleDelay.toLong()) + delay(150) val minDelta = (DEFAULT_CONTENT_SIZE * getDensity()).toInt() val epsilon = 0.5 * getDensity() // 使用 0.5dp 作为误差值 val reachBtm = contentOffset + viewportSize - currentContentSize >= -epsilon - if (isNestedScrollConfigured()) { - return@launch - } - if (contentOffset <= 0 && !isComposeAtTopForScrollSync() && (forceExpand || scrollView?.isDragging != true)) { // 整体把offset 加一下 var delta = calculateBackExpandSize(contentOffset) @@ -380,7 +328,7 @@ internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = f updateContentSizeToRender() } if (pageData?.isOhOs == true) { - delay(16) + delay(25) // 鸿蒙扩容后,不会立刻刷新,也没有刷新api,华为建议添加一个delay来处理 } applyScrollViewOffsetDelta(delta) offsetDirty = true diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt index ad6c1f220..950a6e040 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt @@ -26,7 +26,6 @@ import com.tencent.kuikly.compose.foundation.pager.PagerState import com.tencent.kuikly.compose.views.applyOffsetDelta import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo import com.tencent.kuikly.compose.gestures.KuiklyScrollableState -import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollParams /** @@ -151,15 +150,6 @@ internal suspend fun ScrollableState.animateScrollToTop() { } } -/** - * Whether native nestedScroll is configured on the bound ScrollerView. - */ -internal fun ScrollableState.isNestedScrollConfigured(): Boolean { - val prop = kuiklyInfo.scrollView?.getViewAttr()?.getProp(ScrollerAttr.NESTED_SCROLL) ?: return false - val value = prop.toString() - return value.isNotEmpty() && value != "null" && value != "{}" -} - /** * Check if the native scroll offset should be rejected. * Currently only DrawerInternalPagerState can reject offsets (to guard against diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt index 5e3963683..9e041d5f9 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/Placeable.kt @@ -16,7 +16,6 @@ package com.tencent.kuikly.compose.ui.layout -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.ui.graphics.GraphicsLayerScope import com.tencent.kuikly.compose.ui.node.LookaheadCapablePlaceable import com.tencent.kuikly.compose.ui.node.MotionReferencePlacementDelegate @@ -574,7 +573,6 @@ private class LookaheadCapablePlacementScope( // if coordinates are not null we will only set this flag when the inner // coordinate values are read. see NodeCoordinator.onCoordinatesUsed() if (coords == null) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.placementCoordAccess++ } within.layoutNode.layoutDelegate.onCoordinatesUsed() } return coords diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 508f8b258..fe889cb1d 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -65,9 +65,7 @@ import com.tencent.kuikly.compose.ui.platform.createSubcomposition import com.tencent.kuikly.compose.ui.unit.Constraints import com.tencent.kuikly.compose.ui.unit.LayoutDirection import com.tencent.kuikly.compose.ui.util.fastForEach -import com.tencent.kuikly.compose.ui.util.fastRoundToInt import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.views.KuiklyInfoKey import com.tencent.kuikly.compose.views.VirtualNodeView import com.tencent.kuikly.compose.layout.bindKuiklyInfo @@ -76,7 +74,7 @@ import com.tencent.kuikly.compose.layout.hideOffsetScreenView import com.tencent.kuikly.compose.layout.restoreScrollerViewOnReuse import com.tencent.kuikly.compose.layout.transferScrollToTopCallback import com.tencent.kuikly.compose.scroller.handleScrollToTopCallback -import com.tencent.kuikly.compose.scroller.isNestedScrollConfigured +import com.tencent.kuikly.compose.scroller.isAtTop import com.tencent.kuikly.compose.scroller.lastItemVisible import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.scroller.kuiklyOnScroll @@ -88,7 +86,6 @@ import com.tencent.kuikly.compose.ui.node.KNode.Companion.obtainRenderProps import com.tencent.kuikly.compose.ui.scaleWithDensity import com.tencent.kuikly.core.base.DeclarativeBaseView import com.tencent.kuikly.core.base.event.layoutFrameDidChange -import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollerEvent import com.tencent.kuikly.core.views.ScrollerView @@ -96,9 +93,6 @@ import com.tencent.kuikly.compose.scroller.animateScrollToTop import com.tencent.kuikly.compose.scroller.applyScrollViewOffsetDelta import com.tencent.kuikly.compose.scroller.shouldRejectNativeScrollOffset import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSize -import com.tencent.kuikly.compose.scroller.calculateAndUpdateContentSizeIfNeeded -import com.tencent.kuikly.compose.scroller.finalizeNativeScrollSync -import com.tencent.kuikly.compose.scroller.isAtTop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @@ -300,43 +294,23 @@ fun SubcomposeLayout( scrollEnd { val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() - if (kuiklyInfo.contentOffset != offset) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } - kuiklyInfo.contentOffset = offset - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } - } + kuiklyInfo.contentOffset = offset (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) (scrollableState as? DrawerInternalPagerState)?.onNativeContentOffsetChanged(offset) // 仅触摸滑动结束会回调,api调用和bounce回弹都不会触发 - scrollableState.finalizeNativeScrollSync(offset) + // / back是回滑,forward是前滑 scrollableState.kuiklyOnScrollEnd(scaleParams) - KuiklyScrollTrace.dumpSummary("scrollEnd") - KuiklyScrollTrace.reset() } dragEnd { val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() - if (kuiklyInfo.contentOffset != offset) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } - kuiklyInfo.contentOffset = offset - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } - } - val dragging = kuiklyInfo.scrollView?.isDragging ?: false - if (kuiklyInfo.isDragging != dragging) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingWrites++ } - kuiklyInfo.isDragging = dragging - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingSkipped++ } - } + kuiklyInfo.contentOffset = offset + kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false } scroll { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeScrollReceived++ } val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) - val nativeOffset = if (isVertical) scaleParams.offsetY else scaleParams.offsetX - val offset = nativeOffset.fastRoundToInt() + val offset = if (isVertical) scaleParams.offsetY.toInt() else scaleParams.offsetX.toInt() // Reject unexpected native offset jumps (e.g. HarmonyOS HandleCrashTop). // Correct the native side back and skip this event entirely to prevent @@ -353,21 +327,10 @@ fun SubcomposeLayout( return@scroll } - if (kuiklyInfo.contentOffset != offset) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetWrites++ } - kuiklyInfo.contentOffset = offset - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.contentOffsetSkipped++ } - } + kuiklyInfo.contentOffset = offset (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) (scrollableState as? DrawerInternalPagerState)?.onNativeContentOffsetChanged(offset) - val dragging = kuiklyInfo.scrollView?.isDragging ?: false - if (kuiklyInfo.isDragging != dragging) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingWrites++ } - kuiklyInfo.isDragging = dragging - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.isDraggingSkipped++ } - } + kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false if (kuiklyInfo.ignoreScrollOffset != null) { val ignoreOffset = kuiklyInfo.ignoreScrollOffset!! @@ -377,22 +340,18 @@ fun SubcomposeLayout( if (matched) { kuiklyInfo.ignoreScrollOffset = null } - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll } - // 与 LazyListState 一致:不足 0.5px 的位移先累积,避免鸿蒙高频 sub-pixel onScroll 触发 remeasure - val delta = nativeOffset - kuiklyInfo.composeOffset - if (abs(delta) < 0.5f) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeDeltaFiltered++ } - return@scroll - } - val scrollDelta = delta.fastRoundToInt() - if (scrollDelta == 0) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } + // 忽略较小的滑动 + val delta = offset - kuiklyInfo.composeOffset + if (delta.toInt() == 0) { return@scroll } + // 更新当前的contentSize大小 + scrollableState.calculateAndUpdateContentSize() + val toButtomDelta = if (kuiklyInfo.realContentSize == null) { null } else { @@ -400,36 +359,22 @@ fun SubcomposeLayout( } // 判断是否滑出边界 if (offset < 0 && scrollableState.isAtTop()) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } return@scroll - } else if (scrollableState.isNestedScrollConfigured() && scrollDelta > 0 && !scrollableState.canScrollForward) { - // 嵌套滚动到底:交给外层 ArkUI nestedScroll 消费,Compose 侧不再驱动 remeasure - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } - return@scroll - } else if (toButtomDelta != null && scrollDelta > toButtomDelta) { + } else if (toButtomDelta != null && delta > toButtomDelta) { if (toButtomDelta.toInt() <= 0) { - if (!scrollableState.isNestedScrollConfigured()) { - kuiklyInfo.pendingBottomExpand = true - } - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.composeEarlyReturn++ } + scrollableState.tryExpandStartSize(offset, true) return@scroll } - kuiklyInfo.composeOffset += min(scrollDelta.toFloat(), toButtomDelta) + kuiklyInfo.composeOffset += min(delta, toButtomDelta) } else { - kuiklyInfo.composeOffset = max(0f, kuiklyInfo.composeOffset + scrollDelta) + kuiklyInfo.composeOffset = max(0f, kuiklyInfo.composeOffset + delta) } - // 仅在实际驱动 LazyList 滚动后同步 contentSize / offset 校正 - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.kuiklyOnScroll++ } - val scrollT0 = if (KuiklyScrollTrace.ENABLED) DateTime.nanoTime() else 0L - scrollableState.kuiklyOnScroll(scrollDelta.toFloat()) - scrollableState.calculateAndUpdateContentSizeIfNeeded() - if (!scrollableState.isNestedScrollConfigured()) { - scrollableState.tryExpandStartSize(offset, true) - } - if (KuiklyScrollTrace.ENABLED) { - KuiklyScrollTrace.kuiklyScrollNs += DateTime.nanoTime() - scrollT0 - } + // 触发compose滑动,并重新布局 + val comsumedDelta = scrollableState.kuiklyOnScroll(delta) + + // 尝试扩容 + scrollableState.tryExpandStartSize(offset, true) } // Listen to native "scroll to top" event and scroll to index 0 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt index 827ce81cd..a0b46c00b 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt @@ -30,7 +30,6 @@ import com.tencent.kuikly.compose.ui.layout.LayoutCoordinates import com.tencent.kuikly.compose.ui.platform.LocalDensity import com.tencent.kuikly.compose.ui.unit.IntSize import com.tencent.kuikly.compose.views.VirtualNodeView -import com.tencent.kuikly.compose.gestures.KuiklyScrollTrace import com.tencent.kuikly.compose.layout.resetViewVisible import com.tencent.kuikly.compose.ui.KuiklyPath import com.tencent.kuikly.compose.ui.layout.LookaheadLayoutCoordinates @@ -46,7 +45,6 @@ import com.tencent.kuikly.core.base.Translate import com.tencent.kuikly.core.base.ViewContainer import com.tencent.kuikly.core.base.domChildren import com.tencent.kuikly.core.base.event.notifyLayoutFrameDidChange -import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.layout.Frame import com.tencent.kuikly.core.views.DivView import com.tencent.kuikly.core.views.HoverView @@ -253,8 +251,6 @@ internal class KNode>( } override fun updateKuiklyViewFrame(coordinator: LayoutCoordinates) { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.updateKuiklyViewFrameCalls++ } - val frameT0 = if (KuiklyScrollTrace.ENABLED) DateTime.nanoTime() else 0L val curCoordinator = kuiklyCoordinates ?: innerCoordinator resetViewVisible() @@ -303,9 +299,6 @@ internal class KNode>( } view.updateFrame(newFrame) - if (KuiklyScrollTrace.ENABLED) { - KuiklyScrollTrace.frameComputeNs += DateTime.nanoTime() - frameT0 - } } /** @@ -404,8 +397,6 @@ internal class KNode>( updateScrollViewOffset(curFrame, densityFrame) setFrameToRenderView(densityFrame) getViewEvent().notifyLayoutFrameDidChange(newFrame) - } else { - KuiklyScrollTrace.ifEnabled { KuiklyScrollTrace.frameSyncSkipped++ } } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt index a553fd0cf..d6e1909cf 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt @@ -58,10 +58,6 @@ import com.tencent.kuikly.compose.ui.util.fastAll import com.tencent.kuikly.compose.profiler.RecompositionProfiler import com.tencent.kuikly.core.base.DeclarativeBaseView import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** * Owner of root [LayoutNode]. @@ -123,9 +119,6 @@ internal class RootNodeOwner( // // (which is what we want). // isTraversalGroup = true // } - private val semanticsCoroutineScope = CoroutineScope(coroutineContext) - private var semanticsDebounceJob: Job? = null - val owner: Owner = OwnerImpl(layoutDirection, coroutineContext, rootKView, density) val semanticsOwner = SemanticsOwner(owner.root) private val semanticsKuiklyHandler = KuiklySemantisHandler() @@ -161,8 +154,6 @@ internal class RootNodeOwner( fun dispose() { check(!isDisposed) { "RootNodeOwner is already disposed" } - semanticsDebounceJob?.cancel() - semanticsDebounceJob = null // platformContext.rootForTestListener?.onRootForTestDisposed(rootForTest) snapshotObserver.stopObserving() // graphicsContext.dispose() @@ -408,14 +399,8 @@ internal class RootNodeOwner( ) override fun onSemanticsChange() { - if (!isSemanticsRunnnng) { - return - } - semanticsDebounceJob?.cancel() - semanticsDebounceJob = semanticsCoroutineScope.launch { - delay(100) - semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) - } +// platformContext.semanticsOwnerListener?.onSemanticsChange(semanticsOwner) + semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) } override fun onZIndexChange(layoutNode: LayoutNode) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index ff7c809f9..f74555b2a 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -180,20 +180,16 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { } if (auto rootView = GetRootView().lock()) { if (rootView->IsPerformMainTasking()) { - auto richTextShadow = reinterpret_cast(shadow_.get()); - // typography 已就绪时同步绘制,避免滚动中新 item 白块;未就绪则下一帧 markDirty - if (richTextShadow == nullptr || richTextShadow->MainThreadTypographyHandle() == nullptr) { - std::weak_ptr weakSelf = shared_from_this(); - KRMainThread::RunOnMainThreadForNextLoop([weakSelf] { - if (auto strongSelf = weakSelf.lock()) { - kuikly::util::GetNodeApi()->markDirty(strongSelf->GetNode(), NODE_NEED_RENDER); - } - }); + std::weak_ptr weakSelf = shared_from_this(); + KRMainThread::RunOnMainThreadForNextLoop([weakSelf] { + if(auto strongSelf = weakSelf.lock()){ + kuikly::util::GetNodeApi()->markDirty(strongSelf->GetNode(), NODE_NEED_RENDER); + } + }); #ifndef NDEBUG - KR_LOG_ERROR << "OnForegroundDraw, IsPerformMainTasking Skip:" << shadow_.get(); + KR_LOG_ERROR << "OnForegroundDraw, IsPerformMainTasking Skip:" << shadow_.get(); #endif - return; - } + return; } } auto richTextShadow = reinterpret_cast(shadow_.get()); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp index 682c82d9b..3707bf7fa 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp @@ -15,7 +15,6 @@ #include "libohos_render/expand/components/scroller/KRScrollerView.h" -#include #include #include #include @@ -222,7 +221,6 @@ bool KRScrollerView::ResetProp(const std::string &prop_key) { if (!didHanded) { if (prop_key == kPropNameNestedScroll) { didHanded = true; - has_nested_scroll_ = false; kuikly::util::ResetArkUINestedScroll(GetNode()); } else if (prop_key == kPropNameFlingEnable) { didHanded = true; @@ -255,7 +253,6 @@ void KRScrollerView::CallMethod(const std::string &method, const KRAnyValue &par void KRScrollerView::OnEvent(ArkUI_NodeEvent *event, const ArkUI_NodeEventType &event_type) { if (event_type == NODE_SCROLL_EVENT_ON_SCROLL) { - trace_ark_on_scroll_++; FireOnScrollEvent(event); } else if (event_type == NODE_SCROLL_EVENT_ON_SCROLL_FRAME_BEGIN) { OnScrollFrameBegin(event); @@ -270,18 +267,9 @@ void KRScrollerView::OnEvent(ArkUI_NodeEvent *event, const ArkUI_NodeEventType & } } -void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event, bool force) { +void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event) { auto point = kuikly::util::GetArkUIScrollContentOffset(GetNode()); - // ArkUI reports sub-pixel offsets every frame; quantize to avoid excessive bridge callbacks. - constexpr float kMinScrollOffsetDelta = 0.5f; - constexpr float kFlingScrollOffsetDelta = 2.0f; - const float minDelta = (current_scroll_state_ == ArkUI_ScrollState::ARKUI_SCROLL_STATE_FLING) - ? kFlingScrollOffsetDelta - : kMinScrollOffsetDelta; - if (!force && - fabsf(point.x - last_fired_scroll_x_) < minDelta && - fabsf(point.y - last_fired_scroll_y_) < minDelta) { - trace_fire_skipped_++; + if (point.x == last_fired_scroll_x_ && point.y == last_fired_scroll_y_) { return; } last_fired_scroll_x_ = point.x; @@ -291,7 +279,6 @@ void KRScrollerView::FireOnScrollEvent(ArkUI_NodeEvent *event, bool force) { if (!on_scroll_callback_) { return; } - trace_fire_to_bridge_++; on_scroll_callback_(GetCommonScrollParams()); } @@ -365,9 +352,6 @@ bool KRScrollerView::SetNestedScroll(const KRAnyValue &value) { ArkUI_ScrollNestedMode forward = ParseOption(forwardStr); ArkUI_ScrollNestedMode backward = ParseOption(backwardStr); - has_nested_scroll_ = true; - nested_scroll_forward_ = forward; - nested_scroll_backward_ = backward; kuikly::util::SetArkUINestedScroll(GetNode(), forward, backward); return true; } @@ -587,56 +571,6 @@ void KRScrollerView::OnScrollFrameBegin(ArkUI_NodeEvent *event) { last_scroll_time_ = current_time; last_scroll_x_ = point.x; last_scroll_y_ = point.y; - - if (!has_nested_scroll_ || !content_view_) { - return; - } - auto component_event = OH_ArkUI_NodeEvent_GetNodeComponentEvent(event); - if (!component_event) { - return; - } - const float scroll_amount = component_event->data[0].f32; - const auto frame = GetFrame(); - const auto content_frame = content_view_->GetFrame(); - const float viewport = direction_row_ ? frame.width : frame.height; - const float content_size = direction_row_ ? content_frame.width : content_frame.height; - const float max_offset = std::max(0.f, content_size - viewport); - const float current_offset = direction_row_ ? point.x : point.y; - - float offset_remain = scroll_amount; - if (ShouldHandOffNestedScrollAtBoundary(scroll_amount, current_offset, max_offset)) { - offset_remain = 0.f; - } else if (scroll_amount > 0.f) { - offset_remain = std::min(scroll_amount, std::max(0.f, max_offset - current_offset)); - } else if (scroll_amount < 0.f) { - offset_remain = std::max(scroll_amount, -current_offset); - } - - if (fabsf(offset_remain - scroll_amount) > 0.01f) { - ArkUI_NumberValue ret[] = {{.f32 = offset_remain}}; - OH_ArkUI_NodeEvent_SetReturnNumberValue(event, ret, 1); - } -} - -bool KRScrollerView::ShouldHandOffNestedScrollAtBoundary(float scroll_amount, float current_offset, - float max_offset) const { - if (!has_nested_scroll_) { - return false; - } - constexpr float kBoundaryEpsilon = 0.5f; - const bool at_top = current_offset <= kBoundaryEpsilon; - const bool at_bottom = current_offset >= max_offset - kBoundaryEpsilon; - const auto handoff_mode = [&](bool scrolling_forward) { - const auto mode = scrolling_forward ? nested_scroll_forward_ : nested_scroll_backward_; - return mode == ARKUI_SCROLL_NESTED_MODE_SELF_FIRST || mode == ARKUI_SCROLL_NESTED_MODE_PARENT_FIRST; - }; - if (at_top && scroll_amount < 0.f && handoff_mode(false)) { - return true; - } - if (at_bottom && scroll_amount > 0.f && handoff_mode(true)) { - return true; - } - return false; } void KRScrollerView::OnScrollStop(ArkUI_NodeEvent *event) { @@ -644,13 +578,7 @@ void KRScrollerView::OnScrollStop(ArkUI_NodeEvent *event) { if (is_dragging_) { OnWillDragEnd(event); } - // Flush the final offset so the Compose bridge can sync any sub-threshold remainder. - FireOnScrollEvent(event, true); FireEndScrollEvent(event); - DumpScrollTrace("scrollStop"); - trace_ark_on_scroll_ = 0; - trace_fire_skipped_ = 0; - trace_fire_to_bridge_ = 0; if (auto handler = weak_super_touch_handler_.lock()) { handler->ClearNativeTouchConsumer(shared_from_this()); } @@ -846,18 +774,6 @@ bool KRScrollerView::SetFlingEnable(bool enable) { return true; } -void KRScrollerView::DumpScrollTrace(const char *phase) { -#ifndef NDEBUG - if (trace_ark_on_scroll_ == 0 && trace_fire_to_bridge_ == 0) { - return; - } - KR_LOG_INFO_WITH_TAG("KuiklyScrollTrace") - << phase << " | arkOnScroll=" << trace_ark_on_scroll_ - << " fireSkipped=" << trace_fire_skipped_ - << " fireToBridge=" << trace_fire_to_bridge_; -#endif -} - bool KRScrollerView::SetFlingSpeedLimit(const KRAnyValue &value) { if (!IsFlingSpeedLimitApiAvailable()) { return true; @@ -874,7 +790,7 @@ bool KRScrollerView::SetFlingSpeedLimit(const KRAnyValue &value) { } void KRScrollerView::TryApplyPendingFireOnScroll() { - FireOnScrollEvent(nullptr, true); + FireOnScrollEvent(nullptr); } // Clear transient native state for Compose DSL reuse (not the native reuse pool). @@ -897,7 +813,6 @@ void KRScrollerView::PrepareForComposeReuse() { last_move_time_ = 0; velocity_x_ = 0; velocity_y_ = 0; - has_nested_scroll_ = false; } void KRScrollerView::AbortContentOffsetAnimate() { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h index c877bca48..fe69d6b99 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h @@ -17,7 +17,6 @@ #define CORE_RENDER_OHOS_KRSCROLLERVIEW_H #include -#include #include "KRScrollerContentInset.h" #include "libohos_render/export/IKRRenderViewExport.h" #include "libohos_render/foundation/KRPoint.h" @@ -110,7 +109,7 @@ class KRScrollerView : public IKRRenderViewExport { bool RegisterOnDragEndEvent(const KRRenderCallback event_callback); bool RegisterOnScrollEndEvent(const KRRenderCallback event_callback); bool RegisterWillDragEndEvent(const KRRenderCallback event_callback); - void FireOnScrollEvent(ArkUI_NodeEvent *event, bool force = false); + void FireOnScrollEvent(ArkUI_NodeEvent *event); void FireBeginDragEvent(ArkUI_NodeEvent *event); void FireEndDragEvent(ArkUI_NodeEvent *event); void FireEndScrollEvent(ArkUI_NodeEvent *event); @@ -176,17 +175,6 @@ class KRScrollerView : public IKRRenderViewExport { float last_fired_scroll_x_ = 0; float last_fired_scroll_y_ = 0; bool direction_row_ = false; - - // Scroll trace (debug): counts per gesture, dumped on scroll stop - uint32_t trace_ark_on_scroll_ = 0; - uint32_t trace_fire_skipped_ = 0; - uint32_t trace_fire_to_bridge_ = 0; - void DumpScrollTrace(const char *phase); - bool ShouldHandOffNestedScrollAtBoundary(float scroll_amount, float current_offset, float max_offset) const; - - bool has_nested_scroll_ = false; - ArkUI_ScrollNestedMode nested_scroll_forward_ = ARKUI_SCROLL_NESTED_MODE_SELF_FIRST; - ArkUI_ScrollNestedMode nested_scroll_backward_ = ARKUI_SCROLL_NESTED_MODE_SELF_FIRST; }; #endif // CORE_RENDER_OHOS_KRSCROLLERVIEW_H diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt deleted file mode 100644 index fc2ebfb9c..000000000 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/BugReproCanScrollForward.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Tencent is pleased to support the open source community by making KuiklyUI - * available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the License of KuiklyUI; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tencent.kuikly.demo.pages.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import com.tencent.kuikly.compose.ComposeContainer -import com.tencent.kuikly.compose.foundation.background -import com.tencent.kuikly.compose.foundation.clickable -import com.tencent.kuikly.compose.foundation.layout.Arrangement -import com.tencent.kuikly.compose.foundation.layout.Box -import com.tencent.kuikly.compose.foundation.layout.Column -import com.tencent.kuikly.compose.foundation.layout.PaddingValues -import com.tencent.kuikly.compose.foundation.layout.Spacer -import com.tencent.kuikly.compose.foundation.layout.fillMaxSize -import com.tencent.kuikly.compose.foundation.layout.fillMaxWidth -import com.tencent.kuikly.compose.foundation.layout.height -import com.tencent.kuikly.compose.foundation.layout.offset -import com.tencent.kuikly.compose.foundation.layout.padding -import com.tencent.kuikly.compose.foundation.layout.size -import com.tencent.kuikly.compose.foundation.lazy.LazyColumn -import com.tencent.kuikly.compose.foundation.lazy.rememberLazyListState -import com.tencent.kuikly.compose.foundation.shape.CircleShape -import com.tencent.kuikly.compose.material3.Text -import com.tencent.kuikly.compose.setContent -import com.tencent.kuikly.compose.ui.Alignment -import com.tencent.kuikly.compose.ui.Modifier -import com.tencent.kuikly.compose.ui.draw.clip -import com.tencent.kuikly.compose.ui.graphics.Color -import com.tencent.kuikly.compose.ui.unit.dp -import com.tencent.kuikly.core.annotations.Page - -@Page("5555") -internal class BugReproCanScrollForward : ComposeContainer() { - override fun willInit() { - super.willInit() - setContent { - CanScrollForwardBugDemo() - } - } -} - -@Composable -private fun CanScrollForwardBugDemo() { - val listState = rememberLazyListState() - var showFloatBall by remember { mutableStateOf(false) } - var canScrollForwardValue by remember { mutableStateOf(false) } - var lastScrolledBackwardValue by remember { mutableStateOf(false) } - - LaunchedEffect(listState) { - snapshotFlow { - listState.canScrollForward to listState.lastScrolledBackward - }.collect { (canFwd, scrolledBwd) -> - canScrollForwardValue = canFwd - lastScrolledBackwardValue = scrolledBwd - - if (!canFwd) { - showFloatBall = false - } else if (scrolledBwd) { - showFloatBall = true - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF333333)) - .padding(16.dp) - ) { - Text( - text = "canScrollForward: $canScrollForwardValue\nlastScrolledBackward: $lastScrolledBackwardValue\nshowFloatBall: $showFloatBall", - color = Color.White, - ) - } - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 28.dp, end = 28.dp, top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(50) { index -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .background(if (index % 2 == 0) Color(0xFFEEEEEE) else Color.White) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.CenterStart, - ) { - Text(text = "Item $index") - } - } - item { - Spacer(modifier = Modifier.height(32.dp)) - } - } - } - - if (showFloatBall) { - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .offset(x = (-16).dp, y = (-16).dp) - .size(48.dp) - .clip(CircleShape) - .background(Color.Blue) - .clickable { - // 点击回底 - }, - contentAlignment = Alignment.Center, - ) { - Text("↑", color = Color.White) - } - } - } -} diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt index 430e761c2..f72362cc7 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import com.tencent.kuikly.compose.ComposeContainer +import com.tencent.kuikly.compose.extension.scrollToTop import com.tencent.kuikly.compose.foundation.background import com.tencent.kuikly.compose.foundation.clickable import com.tencent.kuikly.compose.foundation.layout.Arrangement @@ -64,10 +65,6 @@ internal data class DemoItem( @Page("ComposeAllSample") internal class ComposeAllSample : ComposeContainer() { override fun debugUIInspector(): Boolean = true - - /** 本地滚动压测用;恢复 main 时改回 1 即可 */ - private val demoListRepeatCount = 500 - // 预定义一组美观的Material Design颜色 private val demoColors = listOf( @@ -174,27 +171,14 @@ internal class ComposeAllSample : ComposeContainer() { @Composable fun DemoListScreen() { - val demoList = - remember { - val base = getDemoItems() - List(demoListRepeatCount) { index -> - val source = base[index % base.size] - if (index < base.size) { - source - } else { - source.copy( - title = "${source.title} #${index + 1}", - description = "${source.description} (${index + 1}/$demoListRepeatCount)", - pageName = "${source.pageName}_$index", - ) - } - } - } - LaunchedEffect(demoList.size) { - println("ComposeAllSample demoList size=${demoList.size}") + LaunchedEffect(Unit) { + println("DemoListScreen ") } + // 使用抽离出的函数获取演示列表 + val demoList = remember { getDemoItems() } + Column( modifier = Modifier @@ -207,15 +191,6 @@ internal class ComposeAllSample : ComposeContainer() { verticalArrangement = Arrangement.spacedBy(8.dp), // 减小间距 contentPadding = PaddingValues(all = 8.dp), ) { - item { - Text( - text = "压测列表:共 ${demoList.size} 条", - modifier = Modifier.fillMaxWidth().padding(8.dp), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFFE91E63), - ) - } items(demoList) { demo -> DemoItemCard(demo) { navigateToPage(demo) diff --git a/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets b/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets index 2952e66eb..f48bf6a17 100644 --- a/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets +++ b/ohosApp/entry/src/main/ets/entryability/EntryAbility.ets @@ -22,22 +22,9 @@ import fs from '@ohos.file.fs'; import { BusinessError } from '@kit.BasicServicesKit'; import Napi from 'libkuikly_entry.so'; -const LAUNCH_PARAMS_KEY = 'kuiklyLaunchParams'; - export default class EntryAbility extends UIAbility { - private storeLaunchParams(want: Want): void { - if (want.parameters && Object.keys(want.parameters).length > 0) { - AppStorage.setOrCreate(LAUNCH_PARAMS_KEY, want.parameters); - } - } - onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); - this.storeLaunchParams(want); - } - - onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { - this.storeLaunchParams(want); } onDestroy(): void { diff --git a/ohosApp/entry/src/main/ets/pages/Index.ets b/ohosApp/entry/src/main/ets/pages/Index.ets index 76f0bf281..4ff1ea5fd 100644 --- a/ohosApp/entry/src/main/ets/pages/Index.ets +++ b/ohosApp/entry/src/main/ets/pages/Index.ets @@ -23,20 +23,6 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { ContextCodeHandler } from '../kuikly/ContextCodeHandler'; import { AppKRRenderManager } from '../kuikly/adapters/AppKRRenderManager'; -function parsePageData(raw: Object | undefined): KRRecord { - if (raw == null) { - return {}; - } - if (typeof raw === 'string') { - try { - return JSON.parse(raw) as KRRecord; - } catch (_e) { - return {}; - } - } - return raw as KRRecord; -} - @Entry @Component struct Index { @@ -63,16 +49,9 @@ struct Index { aboutToAppear(): void { AppKRRenderManager.getInstance().initIfNeed(); - const routerParams = router.getParams() as Record; - const launchParams = AppStorage.get>('kuiklyLaunchParams'); - const params = (routerParams && Object.keys(routerParams).length > 0) - ? routerParams - : (launchParams ?? {}); - if (launchParams) { - AppStorage.delete('kuiklyLaunchParams'); - } + const params = router.getParams() as Record; this.pageName = params?.pageName as string; - this.pageData = parsePageData(params?.pageData); + this.pageData = (params?.pageData as KRRecord | null) ?? {}; if (this.contextCodeHandler.isNeedGetContextCode(params)) { this.contextCodeHandler.handleGetContextCode(getContext(), params, (contextCode) => { this.contextCode = contextCode; From 4144d2bb8f01f1feb1a8e50b3cbcf52c393d0682 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 7 Jul 2026 20:43:19 +0000 Subject: [PATCH 071/187] fix(android): chip borders use display density, not Paint.density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint.density is a bitmap-scaling field that defaults to 1 — it is not the display density — so 'paint.density * 1dp' collapsed to 1 and the MIN_WIDTH clamp rendered every inline-code/tag chip border at 2 physical px, thinner than react's 1 css px (3 px @3x). Compute the width once from the real display density; both chip border paths share it. Co-Authored-By: Claude Opus 4.8 Signed-off-by: CC-Wow2 --- .../expand/component/text/KRRichTextViewDrawer.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 99b4257a3..06a07e51d 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -275,8 +275,19 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } } + // Paint.density is a bitmap-scaling field that defaults to 1 (it is NOT the + // display density), so `paint.density * 1dp` collapsed to 1 and the + // MIN_WIDTH clamp left every chip border at 2 physical px — thinner than + // react's 1 css px (= 3 px @3x). Use the real display density instead + // (task #407 follow-up, artin's border-width report). + private val slockChipBorderWidthPx: Float = + max( + SLOCK_INLINE_CODE_BORDER_MIN_WIDTH, + android.content.res.Resources.getSystem().displayMetrics.density * SLOCK_INLINE_CODE_BORDER_WIDTH_DP + ) + private fun Canvas.drawSlockInlineCodeBorder(left: Float, top: Float, right: Float, bottom: Float) { - val borderWidth = max(SLOCK_INLINE_CODE_BORDER_MIN_WIDTH, textLayout.paint.density * SLOCK_INLINE_CODE_BORDER_WIDTH_DP) + val borderWidth = slockChipBorderWidthPx val borderLeft = floor(left) val borderTop = floor(top) val borderRight = ceil(right) @@ -288,7 +299,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } private fun Canvas.drawSlockMarkdownTagBorder(left: Float, top: Float, right: Float, bottom: Float) { - val borderWidth = max(SLOCK_INLINE_CODE_BORDER_MIN_WIDTH, textLayout.paint.density * SLOCK_INLINE_CODE_BORDER_WIDTH_DP) + val borderWidth = slockChipBorderWidthPx val borderLeft = floor(left) val borderTop = floor(top) val borderRight = ceil(right) From 9ba117954c886eb46babc92e4a44599801861506 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Wed, 8 Jul 2026 22:38:42 +0800 Subject: [PATCH 072/187] fix(ios): harden text input runtime behavior Disable UIKit autocapitalization/autocorrection for email/password fields and route fatal exception reporting onto the Kuikly context queue when invoked from a K/N worker. Signed-off-by: Codex-Kuikly-KMP --- core-render-ios/Core/KuiklyRenderCore.m | 8 ++++++++ core-render-ios/Extension/Components/KRTextFieldView.m | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core-render-ios/Core/KuiklyRenderCore.m b/core-render-ios/Core/KuiklyRenderCore.m index b54b6554d..1084e8bc0 100644 --- a/core-render-ios/Core/KuiklyRenderCore.m +++ b/core-render-ios/Core/KuiklyRenderCore.m @@ -232,6 +232,14 @@ - (void)p_initContextHandlerWithContextCode:(id)contextCode KR_WEAK_SELF [_contextHandler registerCallNativeWtihCallback:^id _Nullable(KuiklyRenderNativeMethod method, NSArray *_Nonnull args) { KR_STRONG_SELF_RETURN_NIL + if (![KuiklyRenderThreadManager isContextQueue] && + method == KuiklyRenderNativeMethodFireFatalException) { + __block id result = nil; + [KuiklyRenderThreadManager performOnContextQueueWithBlock:^{ + result = [strongSelf p_performNativeMethodWithMethod:method args:args]; + } sync:YES]; + return result; + } [KuiklyRenderThreadManager assertContextQueue]; // 线程断言,保证仅在Context线程回调 // 执行KuiklyKotlin侧调用Native侧的事件 return [strongSelf p_performNativeMethodWithMethod:method args:args]; diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index 56897a36d..7155fd490 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -248,7 +248,14 @@ - (void)setCss_placeholderColor:(NSString *)css_placeholderColor { - (void)setCss_keyboardType:(NSString *)css_keyboardType { self.keyboardType = [KRConvertUtil hr_keyBoardType:css_keyboardType]; - [self setSecureTextEntry:[css_keyboardType isEqualToString:@"password"]]; + BOOL isPassword = [css_keyboardType isEqualToString:@"password"]; + BOOL isEmail = [css_keyboardType isEqualToString:@"email"]; + [self setSecureTextEntry:isPassword]; + if (isEmail || isPassword) { + self.autocapitalizationType = UITextAutocapitalizationTypeNone; + self.autocorrectionType = UITextAutocorrectionTypeNo; + self.spellCheckingType = UITextSpellCheckingTypeNo; + } } - (void)setCss_returnKeyType:(NSString *)css_returnKeyType { @@ -915,4 +922,3 @@ - (NSUInteger)p_calculateCharacterLengthForAttributedText:(NSAttributedString *) @end - From f9c951a08a4a2c700e756f5c37528fb002116f99 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 9 Jul 2026 15:37:06 +0800 Subject: [PATCH 073/187] fix(ios): disable autocap for compose text areas (#6) Signed-off-by: artin --- core-render-ios/Extension/Components/KRTextAreaView.m | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index 87d967e87..092cbd431 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -309,6 +309,14 @@ - (void)setCss_maxTextLength:(NSNumber *)css_maxTextLength { - (void)setCss_keyboardType:(NSString *)css_keyboardType { self.keyboardType = [KRConvertUtil hr_keyBoardType:css_keyboardType]; + BOOL isPassword = [css_keyboardType isEqualToString:@"password"]; + BOOL isEmail = [css_keyboardType isEqualToString:@"email"]; + self.secureTextEntry = isPassword; + if (isEmail || isPassword) { + self.autocapitalizationType = UITextAutocapitalizationTypeNone; + self.autocorrectionType = UITextAutocorrectionTypeNo; + self.spellCheckingType = UITextSpellCheckingTypeNo; + } } - (void)setCss_returnKeyType:(NSString *)css_returnKeyType { From 9bd3a444a3743aac742167866780be4e99ba6857 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 9 Jul 2026 17:32:27 +0800 Subject: [PATCH 074/187] fix(ios): forward textarea tab key events Co-authored-by: Codex-Kuikly-KMP --- .../Extension/Components/KRTextAreaView.m | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index 092cbd431..aecb0646e 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -18,11 +18,14 @@ #import "KRConvertUtil.h" #import "KRRichTextView.h" #import "KuiklyRenderBridge.h" +#import "KuiklyRenderView.h" #import "NSObject+KR.h" // 字典key常量 NSString *const KRFontSizeKey = @"fontSize"; NSString *const KRFontWeightKey = @"fontWeight"; +static const NSInteger KRTextAreaViewKeyEventTypeDown = 2; +static const NSInteger KRTextAreaViewKeyCodeTab = 9; /* * @brief 暴露给Kotlin侧调用的多行输入框组件 @@ -95,6 +98,10 @@ @interface KRTextAreaView() - (BOOL)p_shouldReapplyTextPostProcessorForIncomingRawText:(NSString *)rawText; - (BOOL)p_containsShortcodeToken:(NSString *)rawText; - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; +#if !TARGET_OS_OSX +- (BOOL)p_shouldForwardHardwareTabKey; +- (void)p_forwardHardwareTabKeyWithShiftPressed:(BOOL)shiftPressed; +#endif @end @@ -308,6 +315,7 @@ - (void)setCss_maxTextLength:(NSNumber *)css_maxTextLength { } - (void)setCss_keyboardType:(NSString *)css_keyboardType { + _css_keyboardType = css_keyboardType; self.keyboardType = [KRConvertUtil hr_keyBoardType:css_keyboardType]; BOOL isPassword = [css_keyboardType isEqualToString:@"password"]; BOOL isEmail = [css_keyboardType isEqualToString:@"email"]; @@ -552,6 +560,61 @@ - (void)p_restoreCursorColorInView:(UIView *)view { } #endif +#if !TARGET_OS_OSX +- (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { + if ([self p_shouldForwardHardwareTabKey]) { + for (UIPress *press in presses) { + if (@available(iOS 13.4, *)) { + UIKey *key = press.key; + if ([key.charactersIgnoringModifiers isEqualToString:@"\t"]) { + BOOL shiftPressed = (key.modifierFlags & UIKeyModifierShift) == UIKeyModifierShift; + [self p_forwardHardwareTabKeyWithShiftPressed:shiftPressed]; + return; + } + } + } + } + [super pressesBegan:presses withEvent:event]; +} + +- (NSArray *)keyCommands { + NSArray *superCommands = [super keyCommands]; + if (![self p_shouldForwardHardwareTabKey]) { + return superCommands; + } + NSMutableArray *commands = [NSMutableArray array]; + [commands addObject:[UIKeyCommand keyCommandWithInput:@"\t" + modifierFlags:0 + action:@selector(p_handleHardwareTabKeyCommand:)]]; + [commands addObject:[UIKeyCommand keyCommandWithInput:@"\t" + modifierFlags:UIKeyModifierShift + action:@selector(p_handleHardwareTabKeyCommand:)]]; + [commands addObjectsFromArray:superCommands ?: @[]]; + return commands; +} + +- (BOOL)p_shouldForwardHardwareTabKey { + return [_css_keyboardType isEqualToString:@"email"] || + [_css_keyboardType isEqualToString:@"password"] || + self.secureTextEntry; +} + +- (void)p_handleHardwareTabKeyCommand:(UIKeyCommand *)command { + BOOL shiftPressed = (command.modifierFlags & UIKeyModifierShift) == UIKeyModifierShift; + [self p_forwardHardwareTabKeyWithShiftPressed:shiftPressed]; +} + +- (void)p_forwardHardwareTabKeyWithShiftPressed:(BOOL)shiftPressed { + [self.hr_rootView sendKeyEventWithKeyCode:KRTextAreaViewKeyCodeTab + type:KRTextAreaViewKeyEventTypeDown + utf16CodePoint:KRTextAreaViewKeyCodeTab + altPressed:NO + ctrlPressed:NO + metaPressed:NO + shiftPressed:shiftPressed]; +} +#endif + #if TARGET_OS_OSX - (void)layout { CGRect savedFrame = self.frame; @@ -838,6 +901,12 @@ - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range r if (_ignoreTextDidChanged) { return NO; } +#if !TARGET_OS_OSX + if ([text isEqualToString:@"\t"] && [self p_shouldForwardHardwareTabKey]) { + [self p_forwardHardwareTabKeyWithShiftPressed:NO]; + return NO; + } +#endif if (text == nil || [text isEqualToString:@""]) { // 删除操作 return YES; // It's a delete operation From 1618b8b2009f54c9fba243fd49f781dfacc5a7b4 Mon Sep 17 00:00:00 2001 From: JingKe Date: Thu, 9 Jul 2026 13:14:59 +0000 Subject: [PATCH 075/187] ios(task #439): draw Slock rich-text chip chrome in core-render-ios First cut of the iOS native renderer for the shared rich-text IR chrome tokens (#411 SlockRichTextChromeKind), so inline-code / channel / thread / task / self-mention chips render with border+padding+fill on iOS to match react + the Android drawer, instead of plain text a SpanStyle cannot express. - KRRichTextView: parse the slockMarkdownTagChrome / slockInlineCode span props into a KRSlockChromeAttributeName range attribute; skip the tight NSBackgroundColorAttributeName fill when a chip renders (single fill source). - KRLayoutManager.drawBackgroundForGlyphRange: paint the chip fill (behind text) + black 1dp square-corner border, geometry ported from Android KRRichTextViewDrawer.kt (4/15 padding, 2/15 margin, 24/15 min height). Constants are a TEMPORARY BRIDGE TO TASK #442 (prop-driven token source); each is commented to its shared token symbol (SlockRichTextChromeStyleTokens.* / SLOCK_RICHTEXT_INLINE_CODE_*, mobile PR #435 / 5ffc5a044). ordinaryMention / @other keep the existing underline SpanStyle (no chip). Verified visually via XiShi CI (Linux, no local iOS build). Signed-off-by: JingKe --- .../Extension/AdvancedComps/KRRichTextView.h | 3 + .../Extension/AdvancedComps/KRRichTextView.m | 21 ++- core-render-ios/Extension/Vendor/KRLabel.h | 5 + core-render-ios/Extension/Vendor/KRLabel.m | 132 ++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h index 49416f1ad..c7968e44b 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h @@ -64,6 +64,9 @@ extern NSString *const KuiklyIndexAttributeName; @property (nonatomic, assign) CGFloat strokeWidth; @property (nonatomic, strong) NSShadow *shadow; @property (nonatomic, strong) NSArray *richAttrArray; +// Slock rich-text chip chrome kind (task #439): chrome-kind wire string when this +// span is an inline-code / tag chip, else nil. Consumed by KRLayoutManager. +@property (nonatomic, copy, nullable) NSString *slockChrome; @end diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index 4203d6fd4..ffd4cf417 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -395,6 +395,16 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.strokeWidth = strokeWidth; spanAttrs.shadow = textShadow; spanAttrs.richAttrArray = richAttrArray; + // Slock rich-text chip chrome (task #439): a tag chip carries its chrome kind + // in "slockMarkdownTagChrome" (SLOCK_MARKDOWN_TAG_CHROME); inline code carries + // "slockInlineCode" (SLOCK_INLINE_CODE). Normalize both to a chrome-kind string + // that KRLayoutManager maps to a fill/border. + id slockTagChrome = propStyle[@"slockMarkdownTagChrome"]; + if ([slockTagChrome isKindOfClass:[NSString class]] && [slockTagChrome length]) { + spanAttrs.slockChrome = slockTagChrome; + } else if (propStyle[@"slockInlineCode"]) { + spanAttrs.slockChrome = @"inlineCode"; + } // 组合属性,生成这段Span对应的富文本 NSMutableAttributedString *spanAttrString = [self p_createSpanAttributedStringWithAttributes:spanAttrs]; if (spanAttrString) { @@ -457,10 +467,19 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut [attributedString addAttribute:NSKernAttributeName value:@(attrs.letterSpacing) range:range]; } - if (attrs.backgroundColor) { + if (attrs.backgroundColor && attrs.slockChrome.length == 0) { + // When this span is a Slock chip (task #439), the padded/bordered chip fill + // is drawn by KRLayoutManager; skip the tight NSBackgroundColorAttributeName + // rect so the chip is the single fill source (avoids double-fill on selfMention). [attributedString addAttribute:NSBackgroundColorAttributeName value:attrs.backgroundColor range:range]; } + // Slock rich-text chip chrome (task #439): tag the range so KRLayoutManager draws + // the bordered chip that a plain background attribute cannot express. + if (attrs.slockChrome.length) { + [attributedString addAttribute:KRSlockChromeAttributeName value:attrs.slockChrome range:range]; + } + if (attrs.textDecoration == KRTextDecorationLineTypeUnderline) { NSUnderlineStyle underlineStyle = attrs.textDecorationThickness ? NSUnderlineStyleThick : NSUnderlineStyleSingle; [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(underlineStyle) range:range]; diff --git a/core-render-ios/Extension/Vendor/KRLabel.h b/core-render-ios/Extension/Vendor/KRLabel.h index 8f5970d49..e8b5e1ad0 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.h +++ b/core-render-ios/Extension/Vendor/KRLabel.h @@ -23,6 +23,11 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const KRHighlightAttributeKey; extern NSString *const KRBGAttributeKey; +// Slock rich-text chip chrome (task #439): carries the chrome-kind wire string +// (SlockRichTextChromeKind.wireValue — inlineCode/channel/thread/task/selfMention/active) +// on a span's range so KRLayoutManager can draw the bordered chip that a plain +// text SpanStyle / NSBackgroundColorAttributeName cannot express. +extern NSString *const KRSlockChromeAttributeName; @interface KRLabel : UILabel diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 31bda84f7..8e90d0ffe 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -23,6 +23,53 @@ #define KRAssertMainThread() NSAssert(0 != pthread_main_np(), @"This method must be called on the main thread!") NSString *const KRHighlightAttributeKey = @"KRHighlightAttributeKey"; NSString *const KRBGAttributeKey = @"KRBGAttributeKey"; +NSString *const KRSlockChromeAttributeName = @"KRSlockChromeAttributeName"; + +#pragma mark - Slock rich-text chip chrome (task #439) + +// TEMPORARY BRIDGE TO TASK #442. These constants mirror the Android drawer +// (core-render-android KRRichTextViewDrawer.kt) and the shared token source +// SlockRichTextChromeStyleTokens.* / SLOCK_RICHTEXT_INLINE_CODE_* (mobile PR #435, +// commit 5ffc5a044). #442 will serialize the resolved token fields into the span +// prop so both drawers read prop data and these baked constants are deleted +// (acceptance: fork grep finds no SLOCK constants). Do NOT let these become a new +// long-term source of truth. +// Fill colors: SlockRichTextChromeStyleTokens.InlineCode.chipFill etc. (ARGB). +static const uint32_t kKRSlockInlineCodeFillARGB = 0x66FFD84D; // InlineCode.chipFill (FFD84D @ 40%) +static const uint32_t kKRSlockChannelFillARGB = 0x4DFE7DA8; // Channel.chipFill (pink @ 30%) +static const uint32_t kKRSlockThreadFillARGB = 0x4D27CCF3; // Thread.chipFill (cyan @ 30%) +static const uint32_t kKRSlockTaskFillARGB = 0x66FFD440; // Task.chipFill (yellow @ 40%) +static const uint32_t kKRSlockSelfMentionFillARGB = 0xFFFFD440; // SelfMention.chipFill (opaque yellow) +// Geometry ratios × textSize: SLOCK_RICHTEXT_INLINE_CODE_EDGE_PADDING / _CHAR_WRAP_BREAK et al. +static const CGFloat kKRSlockHorizontalPaddingRatio = 4.0 / 15.0; +static const CGFloat kKRSlockHorizontalMarginRatio = 2.0 / 15.0; +static const CGFloat kKRSlockVerticalPaddingRatio = 2.0 / 15.0; +static const CGFloat kKRSlockMinHeightRatio = 24.0 / 15.0; +static const CGFloat kKRSlockBorderWidthPt = 1.0; // 1dp black border + +static UIColor *KRSlockChromeFillColor(NSString *chrome) { + uint32_t argb; + if ([chrome isEqualToString:@"inlineCode"]) { + argb = kKRSlockInlineCodeFillARGB; + } else if ([chrome isEqualToString:@"channel"]) { + argb = kKRSlockChannelFillARGB; + } else if ([chrome isEqualToString:@"thread"]) { + argb = kKRSlockThreadFillARGB; + } else if ([chrome isEqualToString:@"task"]) { + argb = kKRSlockTaskFillARGB; + } else if ([chrome isEqualToString:@"selfMention"] || [chrome isEqualToString:@"active"]) { + argb = kKRSlockSelfMentionFillARGB; + } else { + // ordinaryMention (and any @other/@agent) renders as an underline via the + // existing text SpanStyle, NOT a chip — no fill/border here. + return nil; + } + CGFloat a = ((argb >> 24) & 0xFF) / 255.0; + CGFloat r = ((argb >> 16) & 0xFF) / 255.0; + CGFloat g = ((argb >> 8) & 0xFF) / 255.0; + CGFloat b = (argb & 0xFF) / 255.0; + return [UIColor colorWithRed:r green:g blue:b alpha:a]; +} @interface KRLabel() @@ -523,8 +570,93 @@ @implementation KRLayoutManager{ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { _drawAtPoint = origin; [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + // Slock chip chrome (task #439). Drawn in drawBackground (before glyphs) so the + // fill sits behind the text; the border is inset from the glyphs by the leading/ + // trailing NBSP padding reserved on the shared side, so it never overlaps glyphs. + [self kr_drawSlockChipChromeForGlyphRange:glyphsToShow atPoint:origin]; _drawAtPoint = CGPointZero; } + +// TEMPORARY BRIDGE TO TASK #442 — ports core-render-android KRRichTextViewDrawer.kt +// drawSlockInlineCodeChrome/drawSlockMarkdownTagChrome geometry to TextKit. #442 moves +// the resolved token values into span props so this reads prop data instead of the +// baked kKRSlock* constants above. +- (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { + NSTextStorage *textStorage = self.textStorage; + if (textStorage.length == 0) { + return; + } + NSTextContainer *container = self.textContainers.firstObject; + if (!container) { + return; + } + NSRange charRange = [self characterRangeForGlyphRange:glyphsToShow actualGlyphRange:NULL]; + if (charRange.length == 0) { + return; + } + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (!ctx) { + return; + } + [textStorage enumerateAttribute:KRSlockChromeAttributeName + inRange:charRange + options:0 + usingBlock:^(id value, NSRange runRange, BOOL *stop) { + if (![value isKindOfClass:[NSString class]] || [(NSString *)value length] == 0) { + return; + } + UIColor *fillColor = KRSlockChromeFillColor((NSString *)value); + if (!fillColor) { + return; // underline-only kinds draw no chip + } + UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:runRange.location effectiveRange:NULL]; + CGFloat textSize = font ? font.pointSize : 15.0; + CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; + CGFloat hMargin = textSize * kKRSlockHorizontalMarginRatio; + CGFloat vPadding = textSize * kKRSlockVerticalPaddingRatio; + CGFloat minHeight = textSize * kKRSlockMinHeightRatio; + (void)hPadding; // continuation-line padding handled by enclosing rects in this first cut + NSRange runGlyphRange = [self glyphRangeForCharacterRange:runRange actualCharacterRange:NULL]; + [self enumerateEnclosingRectsForGlyphRange:runGlyphRange + withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) + inTextContainer:container + usingBlock:^(CGRect rect, BOOL *innerStop) { + CGRect r = CGRectOffset(rect, origin.x, origin.y); + // Mirror the Android leading/trailing edge: the run's glyph bounds include + // the reserved NBSP padding, so pull IN by hMargin on both sides to land the + // border exactly hPadding past the real glyphs (see KRRichTextViewDrawer.kt). + CGFloat left = CGRectGetMinX(r) + hMargin; + CGFloat right = CGRectGetMaxX(r) - hMargin; + if (right <= left) { + return; + } + CGFloat top = CGRectGetMinY(r) - vPadding; + CGFloat bottom = CGRectGetMaxY(r) + vPadding; + CGFloat height = MAX(bottom - top, minHeight); + CGFloat centerY = (top + bottom) / 2.0; + top = centerY - height / 2.0; + bottom = centerY + height / 2.0; + if (bottom <= top) { + return; + } + // CoreGraphics fills (portable across iOS + [macOS]; UIRectFill is iOS-only). + CGContextSetFillColorWithColor(ctx, fillColor.CGColor); + CGContextFillRect(ctx, CGRectMake(left, top, right - left, bottom - top)); + // Black 1dp border, square corners, drawn as four crisp edge rects + // (SlockRichTextChromeStyleTokens border; KRRichTextViewDrawer.drawSlock*Border). + CGFloat bw = kKRSlockBorderWidthPt; + CGFloat bl = floor(left); + CGFloat bt = floor(top); + CGFloat br = ceil(right); + CGFloat bb = ceil(bottom); + CGContextSetFillColorWithColor(ctx, [UIColor blackColor].CGColor); + CGContextFillRect(ctx, CGRectMake(bl, bt, br - bl, bw)); + CGContextFillRect(ctx, CGRectMake(bl, bb - bw, br - bl, bw)); + CGContextFillRect(ctx, CGRectMake(bl, bt, bw, bb - bt)); + CGContextFillRect(ctx, CGRectMake(br - bw, bt, bw, bb - bt)); + }]; + }]; +} - (void)dealloc{ #if DEBUG From a910841b124dc8d357a4b73dfb93eb112fbef39c Mon Sep 17 00:00:00 2001 From: JingKe Date: Thu, 9 Jul 2026 14:43:45 +0000 Subject: [PATCH 076/187] ios(task #439): tighten chip chrome vertical to font metrics + glyph-tight horizontal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #439 residual ① (XiShi pixel-measured): chip fill was offset up ~1/4-1/3 line height because it used enumerateEnclosingRects' line-fragment rect (includes line leading) for the Y bounds — glyphs poked below the fill and the fill top intruded into the line above. Now per line fragment the run spans: - vertical = baseline (locationForGlyphAtIndex) ± font ascender/descender + vPadding, tight to the glyph box like Android KRRichTextViewDrawer / React (no line leading); - horizontal = boundingRectForGlyphRange (tight to the glyphs on THIS line), with Android's run start/end vs wrap-continuation edge logic (margin in / padding out). This also removes the wrapped-segment right overhang (first line of a wrapped chip no longer extends to the line's right edge). Applies to all chip kinds incl. inline code (same draw path). Verified via XiShi CI (Linux, no local iOS build). Signed-off-by: JingKe --- core-render-ios/Extension/Vendor/KRLabel.m | 60 ++++++++++++++-------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 8e90d0ffe..03f4e9dab 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -609,29 +609,49 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi if (!fillColor) { return; // underline-only kinds draw no chip } - UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:runRange.location effectiveRange:NULL]; - CGFloat textSize = font ? font.pointSize : 15.0; - CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; - CGFloat hMargin = textSize * kKRSlockHorizontalMarginRatio; - CGFloat vPadding = textSize * kKRSlockVerticalPaddingRatio; - CGFloat minHeight = textSize * kKRSlockMinHeightRatio; - (void)hPadding; // continuation-line padding handled by enclosing rects in this first cut NSRange runGlyphRange = [self glyphRangeForCharacterRange:runRange actualCharacterRange:NULL]; - [self enumerateEnclosingRectsForGlyphRange:runGlyphRange - withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) - inTextContainer:container - usingBlock:^(CGRect rect, BOOL *innerStop) { - CGRect r = CGRectOffset(rect, origin.x, origin.y); - // Mirror the Android leading/trailing edge: the run's glyph bounds include - // the reserved NBSP padding, so pull IN by hMargin on both sides to land the - // border exactly hPadding past the real glyphs (see KRRichTextViewDrawer.kt). - CGFloat left = CGRectGetMinX(r) + hMargin; - CGFloat right = CGRectGetMaxX(r) - hMargin; + if (runGlyphRange.length == 0) { + return; + } + NSUInteger runGlyphEnd = NSMaxRange(runGlyphRange); + // Per line fragment the run spans: vertical from FONT METRICS (baseline ± + // ascender/descender + vPadding, tight to the glyph box like Android/React) — + // NOT the line-fragment rect (which includes line leading → chip too tall/high, + // task #439 bug ①). Horizontal from boundingRectForGlyphRange (tight to the + // glyphs on THIS line → no wrapped-segment right overhang). + [self enumerateLineFragmentsForGlyphRange:runGlyphRange + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *lineContainer, NSRange lineGlyphRange, BOOL *lineStop) { + if (lineGlyphRange.length == 0) { + return; + } + CGRect gb = [self boundingRectForGlyphRange:lineGlyphRange inTextContainer:lineContainer]; + NSRange lineCharRange = [self characterRangeForGlyphRange:lineGlyphRange actualGlyphRange:NULL]; + UIFont *font = lineCharRange.location < textStorage.length + ? [textStorage attribute:NSFontAttributeName atIndex:lineCharRange.location effectiveRange:NULL] + : nil; + CGFloat textSize = font ? font.pointSize : 15.0; + CGFloat ascender = font ? font.ascender : textSize * 0.75; // > 0, above baseline + CGFloat descender = font ? font.descender : -textSize * 0.25; // < 0, below baseline + CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; + CGFloat hMargin = textSize * kKRSlockHorizontalMarginRatio; + CGFloat vPadding = textSize * kKRSlockVerticalPaddingRatio; + CGFloat minHeight = textSize * kKRSlockMinHeightRatio; + CGPoint loc = [self locationForGlyphAtIndex:lineGlyphRange.location]; + CGFloat baseline = lineRect.origin.y + loc.y + origin.y; + // Android edge logic (KRRichTextViewDrawer.kt): the run's glyph bounds include + // the reserved NBSP padding, so the run start/end edges pull IN by hMargin; + // wrap-continuation edges push OUT by hPadding. + BOOL isRunStart = (lineGlyphRange.location == runGlyphRange.location); + BOOL isRunEnd = (NSMaxRange(lineGlyphRange) >= runGlyphEnd); + CGFloat glyphLeft = CGRectGetMinX(gb) + origin.x; + CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; + CGFloat left = isRunStart ? (glyphLeft + hMargin) : (glyphLeft - hPadding); + CGFloat right = isRunEnd ? (glyphRight - hMargin) : (glyphRight + hPadding); if (right <= left) { return; } - CGFloat top = CGRectGetMinY(r) - vPadding; - CGFloat bottom = CGRectGetMaxY(r) + vPadding; + CGFloat top = baseline - ascender - vPadding; + CGFloat bottom = baseline - descender + vPadding; CGFloat height = MAX(bottom - top, minHeight); CGFloat centerY = (top + bottom) / 2.0; top = centerY - height / 2.0; @@ -642,7 +662,7 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi // CoreGraphics fills (portable across iOS + [macOS]; UIRectFill is iOS-only). CGContextSetFillColorWithColor(ctx, fillColor.CGColor); CGContextFillRect(ctx, CGRectMake(left, top, right - left, bottom - top)); - // Black 1dp border, square corners, drawn as four crisp edge rects + // Black 1dp border, square corners, four crisp edge rects // (SlockRichTextChromeStyleTokens border; KRRichTextViewDrawer.drawSlock*Border). CGFloat bw = kKRSlockBorderWidthPt; CGFloat bl = floor(left); From 9110e97f1ade3ea782bceb8deb13348524b5c4fa Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 9 Jul 2026 23:14:53 +0800 Subject: [PATCH 077/187] fix(ios): apply font family to text inputs (#8) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../compose/foundation/text/BasicTextField.kt | 13 +++++++++++++ .../Extension/Components/KRTextAreaView.m | 11 ++++++++++- .../Extension/Components/KRTextFieldView.m | 13 +++++++++++-- .../com/tencent/kuikly/core/views/TextAreaView.kt | 5 +++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicTextField.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicTextField.kt index e4c7b09f8..a69860d01 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicTextField.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/BasicTextField.kt @@ -36,6 +36,9 @@ import com.tencent.kuikly.compose.ui.graphics.Color import com.tencent.kuikly.compose.ui.graphics.SolidColor import com.tencent.kuikly.compose.ui.text.TextLayoutResult import com.tencent.kuikly.compose.ui.text.TextStyle +import com.tencent.kuikly.compose.ui.text.font.FontFamily +import com.tencent.kuikly.compose.ui.text.font.FontListFontFamily +import com.tencent.kuikly.compose.ui.text.font.GenericFontFamily import com.tencent.kuikly.compose.ui.text.input.ImeAction import com.tencent.kuikly.compose.ui.text.input.KeyboardType import com.tencent.kuikly.compose.ui.text.input.TextFieldValue @@ -43,6 +46,7 @@ import com.tencent.kuikly.compose.ui.text.input.VisualTransformation import com.tencent.kuikly.compose.ui.text.style.TextAlign import com.tencent.kuikly.compose.ui.unit.Density import com.tencent.kuikly.compose.ui.unit.isSpecified +import com.tencent.kuikly.compose.resources.toKuiklyFontFamily import com.tencent.kuikly.core.views.TextAreaAttr internal fun TextAreaAttr.setTextStyle(style: TextStyle, density: Density) { @@ -75,12 +79,21 @@ internal fun TextAreaAttr.setTextStyle(style: TextStyle, density: Density) { fontWeightNormal() } } + applyFontFamily(style.fontFamily) if (style.lineHeight.isSpecified) { lineHeight(this.scaleToDensity(density, style.lineHeight.value)) } } +private fun TextAreaAttr.applyFontFamily(family: FontFamily?) { + when (family) { + is GenericFontFamily -> fontFamily(family.name) + is FontListFontFamily -> fontFamily(family.fonts.toKuiklyFontFamily()) + else -> fontFamily("") + } +} + @Composable fun BasicTextField( state: TextFieldState, diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index aecb0646e..475b3cc5d 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -24,6 +24,7 @@ // 字典key常量 NSString *const KRFontSizeKey = @"fontSize"; NSString *const KRFontWeightKey = @"fontWeight"; +NSString *const KRFontFamilyKey = @"fontFamily"; static const NSInteger KRTextAreaViewKeyEventTypeDown = 2; static const NSInteger KRTextAreaViewKeyCodeTab = 9; @@ -41,6 +42,8 @@ @interface KRTextAreaView() @property (nonatomic, strong) NSNumber *KUIKLY_PROP(fontSize); /** attr is fontWeight */ @property (nonatomic, strong) NSString *KUIKLY_PROP(fontWeight); +/** attr is fontFamily */ +@property (nonatomic, strong) NSString *KUIKLY_PROP(fontFamily); #if TARGET_OS_OSX /** clipPath for macOS - 使用 KUIKLY_PROP 命名规范,仅在 macOS 声明避免覆盖 iOS 上 UIView+CSS category */ @property (nonatomic, copy) NSString *KUIKLY_PROP(clipPath); @@ -291,7 +294,8 @@ - (void)setCss_textAlign:(NSString *)css_textAlign { - (void)setCss_fontSize:(NSNumber *)css_fontSize { _css_fontSize = css_fontSize; self.font = [KRConvertUtil UIFont:@{KRFontSizeKey: css_fontSize ?: @(16), - KRFontWeightKey: _css_fontWeight ?: @"400"}]; + KRFontWeightKey: _css_fontWeight ?: @"400", + KRFontFamilyKey: _css_fontFamily ?: @""}]; [self setNeedsLayout]; } @@ -300,6 +304,11 @@ - (void)setCss_fontWeight:(NSString *)css_fontWeight { [self setCss_fontSize:_css_fontSize]; } +- (void)setCss_fontFamily:(NSString *)css_fontFamily { + _css_fontFamily = css_fontFamily; + [self setCss_fontSize:_css_fontSize]; +} + - (void)setCss_placeholder:(NSString *)css_placeholder { _css_placeholder = css_placeholder; self.placeholderTextView.text = css_placeholder; diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index 7155fd490..418d48b0d 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -21,6 +21,7 @@ // 字典key常量 NSString *const KRVFontSizeKey = @"fontSize"; NSString *const KRVFontWeightKey = @"fontWeight"; +NSString *const KRVFontFamilyKey = @"fontFamily"; /* * @brief 暴露给Kotlin侧调用的多行输入框组件 @@ -34,6 +35,8 @@ @interface KRTextFieldView() @property (nonatomic, strong) NSNumber *KUIKLY_PROP(fontSize); /** attr is fontWeight */ @property (nonatomic, strong) NSString *KUIKLY_PROP(fontWeight); +/** attr is fontFamily */ +@property (nonatomic, strong) NSString *KUIKLY_PROP(fontFamily); /** attr is placeholder */ @property (nonatomic, strong) NSString *KUIKLY_PROP(placeholder); /** attr is textAign */ @@ -82,6 +85,7 @@ - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; @end + @implementation KRTextFieldView { /** text */ NSString *_text; @@ -228,7 +232,8 @@ - (void)setCss_textAlign:(NSString *)css_textAlign { - (void)setCss_fontSize:(NSNumber *)css_fontSize { _css_fontSize = css_fontSize; self.font = [KRConvertUtil UIFont:@{KRVFontSizeKey: css_fontSize ?: @(16), - KRVFontWeightKey: _css_fontWeight ?: @"400"}]; + KRVFontWeightKey: _css_fontWeight ?: @"400", + KRVFontFamilyKey: _css_fontFamily ?: @""}]; } - (void)setCss_fontWeight:(NSString *)css_fontWeight { @@ -236,6 +241,11 @@ - (void)setCss_fontWeight:(NSString *)css_fontWeight { [self setCss_fontSize:_css_fontSize]; } +- (void)setCss_fontFamily:(NSString *)css_fontFamily { + _css_fontFamily = css_fontFamily; + [self setCss_fontSize:_css_fontSize]; +} + - (void)setCss_placeholder:(NSString *)css_placeholder { self.placeholder = css_placeholder; [self p_setNeedUpdatePlaceholder]; @@ -921,4 +931,3 @@ - (NSUInteger)p_calculateCharacterLengthForAttributedText:(NSAttributedString *) } @end - diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt index 4be5de3cd..123735b48 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt @@ -352,6 +352,11 @@ open class TextAreaAttr : Attr() { return this } + fun fontFamily(fontFamily: String): TextAreaAttr { + TextConst.FONT_FAMILY with fontFamily + return this + } + fun textAlignCenter(): TextAreaAttr { TextConst.TEXT_ALIGN with "center" return this From 539736ba9202b3fce4a77b5b537075c93cb47a07 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 9 Jul 2026 23:31:10 +0800 Subject: [PATCH 078/187] fix(ios): preserve dynamic font loading for inputs Preserve Kuikly contextParam when rebuilding iOS TextField/TextArea fonts so dynamic font loading still works with fontFamily propagation. --- .../Extension/Components/KRTextAreaView.m | 26 ++++++++++++++----- .../Extension/Components/KRTextFieldView.m | 24 +++++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index 475b3cc5d..e81f2665a 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -25,6 +25,7 @@ NSString *const KRFontSizeKey = @"fontSize"; NSString *const KRFontWeightKey = @"fontWeight"; NSString *const KRFontFamilyKey = @"fontFamily"; +NSString *const KRFontContextParamKey = @"contextParam"; static const NSInteger KRTextAreaViewKeyEventTypeDown = 2; static const NSInteger KRTextAreaViewKeyCodeTab = 9; @@ -105,6 +106,7 @@ - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; - (BOOL)p_shouldForwardHardwareTabKey; - (void)p_forwardHardwareTabKeyWithShiftPressed:(BOOL)shiftPressed; #endif +- (void)p_updateFont; @end @@ -293,20 +295,17 @@ - (void)setCss_textAlign:(NSString *)css_textAlign { - (void)setCss_fontSize:(NSNumber *)css_fontSize { _css_fontSize = css_fontSize; - self.font = [KRConvertUtil UIFont:@{KRFontSizeKey: css_fontSize ?: @(16), - KRFontWeightKey: _css_fontWeight ?: @"400", - KRFontFamilyKey: _css_fontFamily ?: @""}]; - [self setNeedsLayout]; + [self p_updateFont]; } - (void)setCss_fontWeight:(NSString *)css_fontWeight { _css_fontWeight = css_fontWeight; - [self setCss_fontSize:_css_fontSize]; + [self p_updateFont]; } - (void)setCss_fontFamily:(NSString *)css_fontFamily { _css_fontFamily = css_fontFamily; - [self setCss_fontSize:_css_fontSize]; + [self p_updateFont]; } - (void)setCss_placeholder:(NSString *)css_placeholder { @@ -1069,6 +1068,21 @@ - (void)setTextAlignment:(NSTextAlignment)textAlignment { #pragma mark - private +- (void)p_updateFont { + NSMutableDictionary *fontStyle = [@{ + KRFontSizeKey: _css_fontSize ?: @(16), + KRFontWeightKey: _css_fontWeight ?: @"400" + } mutableCopy]; + if (_css_fontFamily.length > 0) { + fontStyle[KRFontFamilyKey] = _css_fontFamily; + } + if (self.hr_rootView.contextParam) { + fontStyle[KRFontContextParamKey] = self.hr_rootView.contextParam; + } + self.font = [KRConvertUtil UIFont:fontStyle]; + [self setNeedsLayout]; +} + /// iOS 17+ 使用公开属性 insertionPointColor 独立设置光标颜色,避免与 tintColor(选中高亮色)冲突 #if !TARGET_OS_OSX - (void)p_applyNativeCursorColorIfNeeded { diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index 418d48b0d..ec082b767 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -22,6 +22,7 @@ NSString *const KRVFontSizeKey = @"fontSize"; NSString *const KRVFontWeightKey = @"fontWeight"; NSString *const KRVFontFamilyKey = @"fontFamily"; +NSString *const KRVFontContextParamKey = @"contextParam"; /* * @brief 暴露给Kotlin侧调用的多行输入框组件 @@ -82,6 +83,7 @@ @interface KRTextFieldView() - (BOOL)p_containsShortcodeToken:(NSString *)rawText; - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; +- (void)p_updateFont; @end @@ -231,19 +233,17 @@ - (void)setCss_textAlign:(NSString *)css_textAlign { - (void)setCss_fontSize:(NSNumber *)css_fontSize { _css_fontSize = css_fontSize; - self.font = [KRConvertUtil UIFont:@{KRVFontSizeKey: css_fontSize ?: @(16), - KRVFontWeightKey: _css_fontWeight ?: @"400", - KRVFontFamilyKey: _css_fontFamily ?: @""}]; + [self p_updateFont]; } - (void)setCss_fontWeight:(NSString *)css_fontWeight { _css_fontWeight = css_fontWeight; - [self setCss_fontSize:_css_fontSize]; + [self p_updateFont]; } - (void)setCss_fontFamily:(NSString *)css_fontFamily { _css_fontFamily = css_fontFamily; - [self setCss_fontSize:_css_fontSize]; + [self p_updateFont]; } - (void)setCss_placeholder:(NSString *)css_placeholder { @@ -587,6 +587,20 @@ - (void)setFrame:(CGRect)frame { #pragma mark - private +- (void)p_updateFont { + NSMutableDictionary *fontStyle = [@{ + KRVFontSizeKey: _css_fontSize ?: @(16), + KRVFontWeightKey: _css_fontWeight ?: @"400" + } mutableCopy]; + if (_css_fontFamily.length > 0) { + fontStyle[KRVFontFamilyKey] = _css_fontFamily; + } + if (self.hr_rootView.contextParam) { + fontStyle[KRVFontContextParamKey] = self.hr_rootView.contextParam; + } + self.font = [KRConvertUtil UIFont:fontStyle]; +} + - (void)p_addKeyboardNotificationIfNeed { if (_didAddKeyboardNotification) { return ; From 1cf839999285e36a1372e9a1a36d58425db486e9 Mon Sep 17 00:00:00 2001 From: Codex-KMP-Developer Date: Thu, 9 Jul 2026 23:33:29 +0800 Subject: [PATCH 079/187] fix(ios): preserve ellipsis after soft wrap Signed-off-by: Codex-KMP-Developer --- .../kuikly/compose/foundation/text/KuiklyTextExtension.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index a5410dd93..6a0a1ac87 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -267,6 +267,9 @@ internal fun TextAttr.applySoftWrap(softWrap: Boolean) { val target = if (softWrap) "wordWrapping" else "clip" val current = getProp(TextConst.TEXT_OVERFLOW) as? String + if (softWrap && current == "tail") { + return + } if (softWrap && (current == null || current == target) && getProp(TextConst.LINES) == null) { return } From 7c6987cc99cefdd670eac459604fb1805faafa9e Mon Sep 17 00:00:00 2001 From: JingKe Date: Thu, 9 Jul 2026 15:07:02 +0000 Subject: [PATCH 080/187] ios(task #439): clamp chip fill to the token's glyphs per line (fix whole-line fill regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XiShi pixel-caught: the font-metrics vertical fix was right, but the horizontal regressed — the chip fill spanned the ENTIRE line instead of a discrete per-token chip (line 1 all yellow, line 2 all blue, etc.). Root cause: enumerateLineFragmentsForGlyphRange's block glyphRange is the WHOLE line fragment's glyphs, not the run's glyphs on that line. boundingRectForGlyphRange was therefore bounding the whole line. Intersect the line fragment range with the run's glyph range (NSIntersectionRange) so the fill/border bound only this chip token's glyphs per line — discrete chips again. Vertical (baseline ± ascender/descender) kept. Verified via XiShi CI (Linux, no local iOS build). Signed-off-by: JingKe --- core-render-ios/Extension/Vendor/KRLabel.m | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 03f4e9dab..a8eac4acf 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -621,11 +621,16 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi // glyphs on THIS line → no wrapped-segment right overhang). [self enumerateLineFragmentsForGlyphRange:runGlyphRange usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *lineContainer, NSRange lineGlyphRange, BOOL *lineStop) { - if (lineGlyphRange.length == 0) { + // enumerateLineFragmentsForGlyphRange gives the WHOLE line fragment's glyph + // range, not the run's glyphs on that line — intersect with the run so the + // chip fill bounds only THIS token's glyphs (task #439 bug: without this the + // fill spanned the entire line instead of a discrete per-token chip). + NSRange segmentGlyphRange = NSIntersectionRange(lineGlyphRange, runGlyphRange); + if (segmentGlyphRange.length == 0) { return; } - CGRect gb = [self boundingRectForGlyphRange:lineGlyphRange inTextContainer:lineContainer]; - NSRange lineCharRange = [self characterRangeForGlyphRange:lineGlyphRange actualGlyphRange:NULL]; + CGRect gb = [self boundingRectForGlyphRange:segmentGlyphRange inTextContainer:lineContainer]; + NSRange lineCharRange = [self characterRangeForGlyphRange:segmentGlyphRange actualGlyphRange:NULL]; UIFont *font = lineCharRange.location < textStorage.length ? [textStorage attribute:NSFontAttributeName atIndex:lineCharRange.location effectiveRange:NULL] : nil; @@ -636,13 +641,13 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat hMargin = textSize * kKRSlockHorizontalMarginRatio; CGFloat vPadding = textSize * kKRSlockVerticalPaddingRatio; CGFloat minHeight = textSize * kKRSlockMinHeightRatio; - CGPoint loc = [self locationForGlyphAtIndex:lineGlyphRange.location]; + CGPoint loc = [self locationForGlyphAtIndex:segmentGlyphRange.location]; CGFloat baseline = lineRect.origin.y + loc.y + origin.y; // Android edge logic (KRRichTextViewDrawer.kt): the run's glyph bounds include // the reserved NBSP padding, so the run start/end edges pull IN by hMargin; // wrap-continuation edges push OUT by hPadding. - BOOL isRunStart = (lineGlyphRange.location == runGlyphRange.location); - BOOL isRunEnd = (NSMaxRange(lineGlyphRange) >= runGlyphEnd); + BOOL isRunStart = (segmentGlyphRange.location == runGlyphRange.location); + BOOL isRunEnd = (NSMaxRange(segmentGlyphRange) >= runGlyphEnd); CGFloat glyphLeft = CGRectGetMinX(gb) + origin.x; CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; CGFloat left = isRunStart ? (glyphLeft + hMargin) : (glyphLeft - hPadding); From b5e0767c13b6564386d4cbfa64b471d52ff39a0f Mon Sep 17 00:00:00 2001 From: JingKe Date: Thu, 9 Jul 2026 15:31:55 +0000 Subject: [PATCH 081/187] ios(task #439): chip chrome no-underline + px-1 padding + leading-1.5 centering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns iOS chips to React MSG_REF_CHIP (packages/web/.../messageRefChip.ts), per XiShi's pixel-measured checklist: - (2) no underline on chip text: KRRichTextView suppresses NSUnderlineStyleAttribute for chip kinds (inlineCode/channel/thread/task/selfMention/active); the underline from the shared tag span style stays only on ordinaryMention (@other/@agent). - (3) px-1 padding: the fill/border now expands the run's OUTER edges by 4/15·textSize (~4px, React px-1) instead of insetting; wrap-continuation edges stay flush. Fixes the 0px (glyph-tight) fill. - (4) leading-1.5 vertical: chip height = 1.5·fontSize centered on the font vertical center (baseline-(ascender+descender)/2) instead of anchoring full ascender (which left extra top padding). Any residual low-sit is the systemic baseline (LiBai). Discrete/colors/gating unchanged. Verified via XiShi CI (Linux, no local iOS build). Signed-off-by: JingKe --- .../Extension/AdvancedComps/KRRichTextView.m | 8 ++++- core-render-ios/Extension/Vendor/KRLabel.m | 35 ++++++++----------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index ffd4cf417..2ba65b127 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -480,7 +480,13 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut [attributedString addAttribute:KRSlockChromeAttributeName value:attrs.slockChrome range:range]; } - if (attrs.textDecoration == KRTextDecorationLineTypeUnderline) { + // Slock chip chrome (task #439): a chip token (inlineCode/channel/thread/task/ + // selfMention/active) draws a bordered fill and must NOT also carry the text + // underline that the shared span style leaves on tag kinds (React MSG_REF_CHIP has + // no underline). The underline belongs only to ordinaryMention (@other/@agent). + BOOL slockChipSuppressesUnderline = + attrs.slockChrome.length > 0 && ![attrs.slockChrome isEqualToString:@"ordinaryMention"]; + if (attrs.textDecoration == KRTextDecorationLineTypeUnderline && !slockChipSuppressesUnderline) { NSUnderlineStyle underlineStyle = attrs.textDecorationThickness ? NSUnderlineStyleThick : NSUnderlineStyleSingle; [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(underlineStyle) range:range]; if (attrs.textDecorationColor) { diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index a8eac4acf..32392aed0 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -41,11 +41,9 @@ static const uint32_t kKRSlockTaskFillARGB = 0x66FFD440; // Task.chipFill (yellow @ 40%) static const uint32_t kKRSlockSelfMentionFillARGB = 0xFFFFD440; // SelfMention.chipFill (opaque yellow) // Geometry ratios × textSize: SLOCK_RICHTEXT_INLINE_CODE_EDGE_PADDING / _CHAR_WRAP_BREAK et al. -static const CGFloat kKRSlockHorizontalPaddingRatio = 4.0 / 15.0; -static const CGFloat kKRSlockHorizontalMarginRatio = 2.0 / 15.0; -static const CGFloat kKRSlockVerticalPaddingRatio = 2.0 / 15.0; -static const CGFloat kKRSlockMinHeightRatio = 24.0 / 15.0; -static const CGFloat kKRSlockBorderWidthPt = 1.0; // 1dp black border +static const CGFloat kKRSlockHorizontalPaddingRatio = 4.0 / 15.0; // React MSG_REF_CHIP px-1 (≈4px @ 15pt) +static const CGFloat kKRSlockLineHeightRatio = 1.5; // React MSG_REF_CHIP leading-[1.5] +static const CGFloat kKRSlockBorderWidthPt = 1.0; // 1dp black border (border-black) static UIColor *KRSlockChromeFillColor(NSString *chrome) { uint32_t argb; @@ -637,30 +635,27 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat textSize = font ? font.pointSize : 15.0; CGFloat ascender = font ? font.ascender : textSize * 0.75; // > 0, above baseline CGFloat descender = font ? font.descender : -textSize * 0.25; // < 0, below baseline - CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; - CGFloat hMargin = textSize * kKRSlockHorizontalMarginRatio; - CGFloat vPadding = textSize * kKRSlockVerticalPaddingRatio; - CGFloat minHeight = textSize * kKRSlockMinHeightRatio; + CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; // React px-1 ≈ 4px each side + CGFloat chipHeight = textSize * kKRSlockLineHeightRatio; // React leading-[1.5] CGPoint loc = [self locationForGlyphAtIndex:segmentGlyphRange.location]; CGFloat baseline = lineRect.origin.y + loc.y + origin.y; - // Android edge logic (KRRichTextViewDrawer.kt): the run's glyph bounds include - // the reserved NBSP padding, so the run start/end edges pull IN by hMargin; - // wrap-continuation edges push OUT by hPadding. BOOL isRunStart = (segmentGlyphRange.location == runGlyphRange.location); BOOL isRunEnd = (NSMaxRange(segmentGlyphRange) >= runGlyphEnd); CGFloat glyphLeft = CGRectGetMinX(gb) + origin.x; CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; - CGFloat left = isRunStart ? (glyphLeft + hMargin) : (glyphLeft - hPadding); - CGFloat right = isRunEnd ? (glyphRight - hMargin) : (glyphRight + hPadding); + // React MSG_REF_CHIP px-1: the fill is hPadding wider than the glyphs on each + // OUTER edge of the run; a wrap-continuation edge stays flush with the break. + CGFloat left = isRunStart ? (glyphLeft - hPadding) : glyphLeft; + CGFloat right = isRunEnd ? (glyphRight + hPadding) : glyphRight; if (right <= left) { return; } - CGFloat top = baseline - ascender - vPadding; - CGFloat bottom = baseline - descender + vPadding; - CGFloat height = MAX(bottom - top, minHeight); - CGFloat centerY = (top + bottom) / 2.0; - top = centerY - height / 2.0; - bottom = centerY + height / 2.0; + // React leading-[1.5]: a 1.5·fontSize tall box centered on the font's vertical + // center (baseline - (ascender+descender)/2) so the glyph is centered with + // symmetric top/bottom padding (any residual low-sit is the systemic baseline). + CGFloat centerY = baseline - (ascender + descender) / 2.0; + CGFloat top = centerY - chipHeight / 2.0; + CGFloat bottom = centerY + chipHeight / 2.0; if (bottom <= top) { return; } From 067c6e07321cdb96be159169b7e87ee8eea891c1 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 04:43:31 +0800 Subject: [PATCH 082/187] fix(android): clamp pull refresh overscroll (#10) Signed-off-by: Codex-Kuikly-KMP --- .../compose/gestures/KuiklyScrollInfo.kt | 15 +++++ .../kuikly/compose/material3/PullToRefresh.kt | 5 +- .../expand/component/list/KRRecyclerView.kt | 62 ++++++++++++++++++- .../component/list/OverScrollHandler.kt | 21 ++++++- .../list/PullToRefreshOverscrollClampTest.kt | 43 +++++++++++++ .../tencent/kuikly/core/views/ScrollerView.kt | 8 ++- 6 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 837d857be..434c304a5 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -132,10 +132,22 @@ class KuiklyScrollInfo { */ var hasPullToRefresh: Boolean = false set(value) { + if (field == value) return field = value scrollView?.let { updatePullToRefreshOnScrollView(it, value) } } + /** Maximum visible top overscroll for pull-to-refresh, in logical pixels. */ + var pullToRefreshMaxDistance: Float = 0f + set(value) { + val normalized = value.coerceAtLeast(0f) + if (field == normalized) return + field = normalized + if (hasPullToRefresh) { + scrollView?.let { updatePullToRefreshOnScrollView(it, true) } + } + } + private fun updatePullToRefreshOnScrollView( targetScrollView: ScrollerView, enabled: Boolean @@ -144,6 +156,9 @@ class KuiklyScrollInfo { fun applyIfCurrent() { if (scrollView === targetScrollView && hasPullToRefresh == enabled) { targetScrollView.setHasPullToRefresh(enabled) + targetScrollView.setPullToRefreshMaxDistance( + if (enabled) pullToRefreshMaxDistance else 0f + ) } } if (KuiklyContextScheduler.isOnKuiklyThread(pagerId)) { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt index 3eb190d1f..0f5b63615 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt @@ -192,7 +192,8 @@ fun LazyListScope.pullToRefreshItem( DefaultRefreshIndicator(progress, refreshing, threshold) } ) { - // Mark that the current list uses PullToRefresh + // Mark the list and publish its live Android overscroll limit before the first gesture. + scrollState.kuiklyInfo.pullToRefreshMaxDistance = refreshThreshold.value scrollState.kuiklyInfo.hasPullToRefresh = true item(key = "pull_to_refresh") { @@ -418,4 +419,4 @@ private fun DefaultRefreshIndicator( modifier = Modifier.padding(16.dp) ) } -} \ No newline at end of file +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt index be3801e86..a2ceebc1a 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt @@ -40,6 +40,7 @@ import com.tencent.kuikly.core.render.android.css.ktx.frameWidth import com.tencent.kuikly.core.render.android.css.ktx.nativeGestureViewHashCodeSet import com.tencent.kuikly.core.render.android.css.ktx.touchConsumeByKuikly import com.tencent.kuikly.core.render.android.css.ktx.toDpF +import com.tencent.kuikly.core.render.android.css.ktx.toPxF import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.export.IKuiklyRenderViewExport import com.tencent.kuikly.core.render.android.export.KuiklyRenderCallback @@ -120,6 +121,9 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private var bouncesEnable = true internal var limitHeaderBounces = false + private var hasPullToRefresh = false + private var pullToRefreshMaxTranslationPx = 0f + /** * List上一次的滚动状态 */ @@ -502,7 +506,8 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? { return when (method) { - METHOD_SET_HAS_PULL_TO_REFRESH -> null + METHOD_SET_HAS_PULL_TO_REFRESH -> setHasPullToRefresh(params) + METHOD_SET_PULL_TO_REFRESH_MAX_DISTANCE -> setPullToRefreshMaxDistance(params) METHOD_CONTENT_OFFSET -> setContentOffset(params) METHOD_CONTENT_INSET_WHEN_END_DRAG -> contentInsetWhenEndDrag(params) METHOD_CONTENT_INSET -> contentInset(params) @@ -1314,6 +1319,47 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi overScrollHandler?.bounceWithContentInset(KRRecyclerContentViewContentInset(kuiklyRenderContext, ci)) } + private fun setHasPullToRefresh(params: String?) { + val enabled = params == "1" + if (hasPullToRefresh == enabled) return + hasPullToRefresh = enabled + if (!enabled) { + pullToRefreshMaxTranslationPx = 0f + } + KuiklyRenderLog.d( + VIEW_NAME, + "$PULL_TO_REFRESH_CLAMP_MARKER enabled=$enabled maxPx=$pullToRefreshMaxTranslationPx" + ) + } + + private fun setPullToRefreshMaxDistance(params: String?) { + val logicalDistance = params?.toFloatOrNull()?.coerceAtLeast(0f) ?: return + val maxTranslationPx = kuiklyRenderContext.toPxF(logicalDistance) + if (pullToRefreshMaxTranslationPx == maxTranslationPx) return + pullToRefreshMaxTranslationPx = maxTranslationPx + overScrollHandler?.clampPullToRefreshTranslationIfNeeded() + KuiklyRenderLog.d( + VIEW_NAME, + "$PULL_TO_REFRESH_CLAMP_MARKER enabled=$hasPullToRefresh " + + "logical=$logicalDistance maxPx=$maxTranslationPx" + ) + } + + internal fun clampPullToRefreshTranslation(value: Float): Float = + clampPullToRefreshTranslation( + value = value, + enabled = hasPullToRefresh, + maxTranslation = pullToRefreshMaxTranslationPx + ) + + internal fun logPullToRefreshClamp(rawValue: Float, clampedValue: Float) { + KuiklyRenderLog.d( + VIEW_NAME, + "$PULL_TO_REFRESH_CLAMP_MARKER rawPx=$rawValue " + + "clampedPx=$clampedValue maxPx=$pullToRefreshMaxTranslationPx" + ) + } + /** * Clear transient native state for Compose DSL reuse (not the native reuse pool). */ @@ -1333,6 +1379,9 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi lastScrollParentX = 0 lastScrollParentY = 0 nestedScrollLastMoveTime = 0L + // Reset pull-to-refresh bounds before this native list is reused by another Compose node. + hasPullToRefresh = false + pullToRefreshMaxTranslationPx = 0f // Reset position offset compensation accumulatedPositionOffsetX = 0 accumulatedPositionOffsetY = 0 @@ -1498,6 +1547,8 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private const val METHOD_ABORT_CONTENT_OFFSET_ANIMATE = "abortContentOffsetAnimate" // 停止滚动动画 private const val METHOD_PREPARE_FOR_COMPOSE_REUSE = "prepareForComposeReuse" // Compose DSL 复用前重置瞬态 private const val METHOD_SET_HAS_PULL_TO_REFRESH = "setHasPullToRefresh" + private const val METHOD_SET_PULL_TO_REFRESH_MAX_DISTANCE = "setPullToRefreshMaxDistance" + private const val PULL_TO_REFRESH_CLAMP_MARKER = "kuikly_ptr_overscroll_clamp_v1" private const val NESTED_SCROLL = "nestedScroll" @@ -2024,3 +2075,12 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi return super.canScrollVertically(direction) } } + +internal fun clampPullToRefreshTranslation( + value: Float, + enabled: Boolean, + maxTranslation: Float +): Float { + if (!enabled || maxTranslation <= 0f || value <= 0f) return value + return value.coerceAtMost(maxTranslation) +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt index 1cfde0f54..f9c07223a 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt @@ -84,6 +84,7 @@ internal class OverScrollHandler( */ private var accumulatedTranslationX: Float = 0f private var accumulatedTranslationY: Float = 0f + private var didLogPullToRefreshClamp = false private val maxFlingVelocity = ViewConfiguration.get(recyclerView.context).scaledMaximumFlingVelocity private var velocityTracker = VelocityTracker.obtain() @@ -125,6 +126,7 @@ internal class OverScrollHandler( overScrollY = 0f accumulatedTranslationX = 0f accumulatedTranslationY = 0f + didLogPullToRefreshClamp = false contentInsetWhenEndDrag = null } @@ -142,6 +144,7 @@ internal class OverScrollHandler( private fun processDownEvent(activeIndex: Int, event: MotionEvent): Boolean { downing = true + didLogPullToRefreshClamp = false updatePointerData(activeIndex, event) if (forceOverScroll) { dragging = true @@ -415,7 +418,12 @@ internal class OverScrollHandler( ) { accumulatedTranslationY = contentView.translationY } - accumulatedTranslationY += offset + val rawTranslation = accumulatedTranslationY + offset + accumulatedTranslationY = recyclerView.clampPullToRefreshTranslation(rawTranslation) + if (rawTranslation != accumulatedTranslationY && !didLogPullToRefreshClamp) { + recyclerView.logPullToRefreshClamp(rawTranslation, accumulatedTranslationY) + didLogPullToRefreshClamp = true + } contentView.translationY = accumulatedTranslationY.roundToInt().toFloat() } else { if (accumulatedTranslationX != contentView.translationX && @@ -428,6 +436,15 @@ internal class OverScrollHandler( } } + fun clampPullToRefreshTranslationIfNeeded() { + if (!isVertical) return + val clamped = recyclerView.clampPullToRefreshTranslation(accumulatedTranslationY) + if (clamped == accumulatedTranslationY) return + accumulatedTranslationY = clamped + contentView.translationY = clamped.roundToInt().toFloat() + fireOverScrollCallback(contentView.translationX, contentView.translationY) + } + private fun updatePointerData(activeIndex: Int, motionEvent: MotionEvent) { val pointerId = motionEvent.getPointerId(activeIndex) val currentOffset = getCurrentOffset(activeIndex, motionEvent) @@ -491,4 +508,4 @@ internal interface OverScrollEventCallback { overScrollStart: Boolean, isDragging: Boolean ) -} \ No newline at end of file +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt new file mode 100644 index 000000000..764c25a68 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt @@ -0,0 +1,43 @@ +package com.tencent.kuikly.core.render.android.expand.component.list + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PullToRefreshOverscrollClampTest { + @Test + fun clampsPositiveTopOverscrollToRefreshThreshold() { + assertEquals( + 240f, + clampPullToRefreshTranslation(value = 440f, enabled = true, maxTranslation = 240f), + 0f + ) + } + + @Test + fun keepsTranslationWithinThreshold() { + assertEquals( + 180f, + clampPullToRefreshTranslation(value = 180f, enabled = true, maxTranslation = 240f), + 0f + ) + } + + @Test + fun leavesNonPullToRefreshAndBottomOverscrollUnchanged() { + assertEquals( + 440f, + clampPullToRefreshTranslation(value = 440f, enabled = false, maxTranslation = 240f), + 0f + ) + assertEquals( + -440f, + clampPullToRefreshTranslation(value = -440f, enabled = true, maxTranslation = 240f), + 0f + ) + assertEquals( + 440f, + clampPullToRefreshTranslation(value = 440f, enabled = true, maxTranslation = 0f), + 0f + ) + } +} diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt index b1161d096..33077d524 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt @@ -399,6 +399,12 @@ open class ScrollerView : renderView?.callMethod("setHasPullToRefresh", if (enabled) "1" else "0", null) } } + + fun setPullToRefreshMaxDistance(maxDistance: Float) { + performTaskWhenRenderViewDidLoad { + renderView?.callMethod("setPullToRefreshMaxDistance", maxDistance.coerceAtLeast(0f).toString(), null) + } + } } enum class KRNestedScrollMode(val value: String){ @@ -846,4 +852,4 @@ data class SetContentOffsetAnimation(private val durationMs: Int, val damping: F return SetContentOffsetAnimation(durationMs, damping, velocity); } } -} \ No newline at end of file +} From bd2ad40f924de374f7e09181b34102a6b20fbae0 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 04:43:53 +0800 Subject: [PATCH 083/187] ios(task #448): model rich-text inline boxes atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the accepted iOS chip chrome base, model reference chips as atomic attachments, and model inline code with the Android-compatible ≤16 atom / >16 grapheme-chain contract. Attachments own measurement, advance, and original-text semantics; KRLayoutManager paints continuous chrome after final TextKit wrapping.\n\nSigned-off-by: artin \nSigned-off-by: Codex-Kuikly-KMP \nSigned-off-by: JingKe --- .../Extension/AdvancedComps/KRRichTextView.m | 365 ++++++++++++++++++ .../TextSelection/KRTextSelectionHelper.m | 77 +++- core-render-ios/Extension/Vendor/KRLabel.h | 8 +- core-render-ios/Extension/Vendor/KRLabel.m | 104 ++++- 4 files changed, 512 insertions(+), 42 deletions(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index 2ba65b127..e6fe408d0 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -18,12 +18,234 @@ #import "KRConvertUtil.h" #import "KuiklyRenderBridge.h" #import "NSObject+KR.h" +#import NSString *const KuiklyIndexAttributeName = @"KuiklyIndexAttributeName"; NSString *const kGradientInfoKeyCSSGradient = @"cssGradient"; NSString *const kGradientInfoKeyFont = @"font"; NSString *const kGradientInfoKeyGlobalRange = @"globalRange"; +static const CGFloat kKRSlockAtomicChipHorizontalPaddingRatio = 4.0 / 15.0; +static const CGFloat kKRSlockAtomicChipHorizontalMarginRatio = 2.0 / 15.0; +static const CGFloat kKRSlockAtomicChipLineHeightRatio = 1.5; +static const CGFloat kKRSlockAtomicChipBorderWidth = 1.0; +static const NSUInteger kKRSlockInlineCodeAtomizeThreshold = 16; + +static UIColor *KRSlockAtomicChipFillColor(NSString *chrome, UIColor *resolvedStyleFill) { + if (resolvedStyleFill && CGColorGetAlpha(resolvedStyleFill.CGColor) > 0) { + return resolvedStyleFill; + } + uint32_t argb = 0; + if ([chrome isEqualToString:@"channel"]) { + argb = 0x4DFE7DA8; + } else if ([chrome isEqualToString:@"thread"]) { + argb = 0x4D27CCF3; + } else if ([chrome isEqualToString:@"task"]) { + argb = 0x66FFD440; + } else if ([chrome isEqualToString:@"selfMention"] || [chrome isEqualToString:@"active"]) { + argb = 0xFFFFD440; + } + CGFloat a = ((argb >> 24) & 0xFF) / 255.0; + CGFloat r = ((argb >> 16) & 0xFF) / 255.0; + CGFloat g = ((argb >> 8) & 0xFF) / 255.0; + CGFloat b = (argb & 0xFF) / 255.0; + return [UIColor colorWithRed:r green:g blue:b alpha:a]; +} + +// TextKit has no native inline-box model for an attributed-string subrange. A +// Slock reference chip is therefore represented as one attachment whose bounds +// are the complete inline box (text + padding + transparent outer margin). This +// keeps measurement, wrapping and drawing on the same atomic layout object, +// matching Android's ReplacementSpan instead of painting outside glyph advance. +@interface KRSlockAtomicChipAttachment : NSTextAttachment + +@property (nonatomic, copy) NSString *originalText; + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + fillColor:(UIColor *)fillColor + letterSpacing:(CGFloat)letterSpacing; + +@end + +@implementation KRSlockAtomicChipAttachment + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + fillColor:(UIColor *)fillColor + letterSpacing:(CGFloat)letterSpacing { + if (self = [super init]) { + _originalText = [text copy] ?: @""; + UIFont *resolvedFont = font ?: [UIFont systemFontOfSize:15.0]; + UIColor *resolvedTextColor = textColor ?: [UIColor blackColor]; + UIColor *resolvedFillColor = fillColor ?: [UIColor clearColor]; + NSMutableDictionary *attributes = [@{ + NSFontAttributeName: resolvedFont, + NSForegroundColorAttributeName: resolvedTextColor, + } mutableCopy]; + if (letterSpacing != 0) { + attributes[NSKernAttributeName] = @(letterSpacing); + } + NSAttributedString *displayText = [[NSAttributedString alloc] initWithString:_originalText attributes:attributes]; + CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)displayText); + CGFloat ascent = 0; + CGFloat descent = 0; + CGFloat leading = 0; + CGFloat textWidth = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); + CGFloat textSize = resolvedFont.pointSize; + CGFloat innerPadding = textSize * kKRSlockAtomicChipHorizontalPaddingRatio; + CGFloat outerMargin = textSize * kKRSlockAtomicChipHorizontalMarginRatio; + CGFloat edgeAdvance = innerPadding + outerMargin; + CGFloat chipHeight = textSize * kKRSlockAtomicChipLineHeightRatio; + CGFloat totalWidth = textWidth + edgeAdvance * 2.0; + + UIGraphicsBeginImageContextWithOptions(CGSizeMake(totalWidth, chipHeight), NO, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context) { + CGRect chromeRect = CGRectMake(outerMargin, 0, totalWidth - outerMargin * 2.0, chipHeight); + CGContextSetFillColorWithColor(context, resolvedFillColor.CGColor); + CGContextFillRect(context, chromeRect); + + CGFloat borderWidth = kKRSlockAtomicChipBorderWidth; + CGFloat left = CGRectGetMinX(chromeRect); + CGFloat top = CGRectGetMinY(chromeRect); + CGFloat right = CGRectGetMaxX(chromeRect); + CGFloat bottom = CGRectGetMaxY(chromeRect); + CGContextSetFillColorWithColor(context, [UIColor blackColor].CGColor); + CGContextFillRect(context, CGRectMake(left, top, right - left, borderWidth)); + CGContextFillRect(context, CGRectMake(left, bottom - borderWidth, right - left, borderWidth)); + CGContextFillRect(context, CGRectMake(left, top, borderWidth, bottom - top)); + CGContextFillRect(context, CGRectMake(right - borderWidth, top, borderWidth, bottom - top)); + + CGContextSaveGState(context); + CGContextTranslateCTM(context, 0, chipHeight); + CGContextScaleCTM(context, 1.0, -1.0); + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGFloat baseline = (chipHeight - ascent + descent) / 2.0; + CGContextSetTextPosition(context, edgeAdvance, baseline); + CTLineDraw(line, context); + CGContextRestoreGState(context); + } + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + CFRelease(line); + + self.image = image; + CGFloat baselineOffset = (resolvedFont.ascender + resolvedFont.descender) / 2.0 - chipHeight / 2.0; + self.bounds = CGRectMake(0, baselineOffset, totalWidth, chipHeight); + } + return self; +} + +- (NSString *)kr_originlTextBeforeTextAttachment { + return self.originalText ?: @""; +} + +@end + +// Inline code uses the same atomic inline-box model as reference chips, at a +// finer granularity: one attachment per composed grapheme. Each atom owns its +// glyph measurement/drawing and original text, while KRLayoutManager paints one +// continuous chrome fragment after TextKit has chosen the final line breaks. +@interface KRSlockInlineCodeAtomAttachment : NSTextAttachment + +@property (nonatomic, copy) NSString *originalText; +@property (nonatomic, assign) BOOL leadingEdge; +@property (nonatomic, assign) BOOL trailingEdge; + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + letterSpacing:(CGFloat)letterSpacing + leadingEdge:(BOOL)leadingEdge + trailingEdge:(BOOL)trailingEdge; + +@end + +@implementation KRSlockInlineCodeAtomAttachment + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + letterSpacing:(CGFloat)letterSpacing + leadingEdge:(BOOL)leadingEdge + trailingEdge:(BOOL)trailingEdge { + if (self = [super init]) { + _originalText = [text copy] ?: @""; + _leadingEdge = leadingEdge; + _trailingEdge = trailingEdge; + UIFont *resolvedFont = font ?: [UIFont systemFontOfSize:15.0]; + UIColor *resolvedTextColor = textColor ?: [UIColor blackColor]; + NSMutableDictionary *attributes = [@{ + NSFontAttributeName: resolvedFont, + NSForegroundColorAttributeName: resolvedTextColor, + } mutableCopy]; + if (letterSpacing != 0) { + attributes[NSKernAttributeName] = @(letterSpacing); + } + NSAttributedString *displayText = [[NSAttributedString alloc] initWithString:_originalText attributes:attributes]; + CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)displayText); + CGFloat ascent = 0; + CGFloat descent = 0; + CGFloat leading = 0; + CGFloat textWidth = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); + CGFloat textSize = resolvedFont.pointSize; + CGFloat innerPadding = textSize * kKRSlockAtomicChipHorizontalPaddingRatio; + CGFloat outerMargin = textSize * kKRSlockAtomicChipHorizontalMarginRatio; + CGFloat edgeAdvance = innerPadding + outerMargin; + CGFloat leadingAdvance = leadingEdge ? edgeAdvance : 0.0; + CGFloat trailingAdvance = trailingEdge ? edgeAdvance : 0.0; + CGFloat atomHeight = textSize * kKRSlockAtomicChipLineHeightRatio; + CGFloat totalWidth = textWidth + leadingAdvance + trailingAdvance; + + UIGraphicsBeginImageContextWithOptions(CGSizeMake(totalWidth, atomHeight), NO, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context) { + CGContextSaveGState(context); + CGContextTranslateCTM(context, 0, atomHeight); + CGContextScaleCTM(context, 1.0, -1.0); + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGFloat baseline = (atomHeight - ascent + descent) / 2.0; + CGContextSetTextPosition(context, leadingAdvance, baseline); + CTLineDraw(line, context); + CGContextRestoreGState(context); + } + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + CFRelease(line); + + self.image = image; + CGFloat baselineOffset = (resolvedFont.ascender + resolvedFont.descender) / 2.0 - atomHeight / 2.0; + self.bounds = CGRectMake(0, baselineOffset, totalWidth, atomHeight); + } + return self; +} + +- (NSString *)kr_originlTextBeforeTextAttachment { + return self.originalText ?: @""; +} + +- (BOOL)kr_slockInlineCodeLeadingEdge { + return self.leadingEdge; +} + +- (BOOL)kr_slockInlineCodeTrailingEdge { + return self.trailingEdge; +} + +@end + +static BOOL KRSlockUsesAtomicChipBox(NSString *chrome) { + // Reference chips are indivisible inline boxes. Inline code is handled by + // KRSlockInlineCodeAtomAttachment instead: one box for short spans and a + // grapheme box chain for long spans, preserving #58 character wrapping. + return chrome.length > 0 && + ![chrome isEqualToString:@"ordinaryMention"] && + ![chrome isEqualToString:@"inlineCode"]; +} + @interface KRRichTextView() @property (nonatomic, strong) NSNumber *css_numberOfLines; @@ -428,11 +650,154 @@ - (NSMutableAttributedString *)p_buildAttributedString { resAttr = [[KuiklyRenderBridge componentExpandHandler] hr_customTextWithAttributedString:resAttr textPostProcessor:textPostProcessor]; } } + [self p_reserveSlockChipBoxAdvance:resAttr]; return resAttr; } +// task #439 ⑥: reserve the chip's inline-box advance (px-1 padding + 1px border) in +// LAYOUT via kern, so neighbors are pushed outside the box like React's inline-block +// (border→neighbor keeps a ~1-space gap) instead of laying out into the painted +// fill/border region (which made chips look glued to adjacent text, margin≈0). +// Leading reserve goes on the char BEFORE the run; trailing on the run's last char. +// The trailing kern inflates the run's boundingRect — KRLayoutManager accounts for it. +- (void)p_reserveSlockChipBoxAdvance:(NSMutableAttributedString *)str { + if (str.length == 0) { + return; + } + NSMutableArray *chipRanges = [NSMutableArray new]; + [str enumerateAttribute:KRSlockChromeAttributeName + inRange:NSMakeRange(0, str.length) + options:0 + usingBlock:^(id value, NSRange r, BOOL *stop) { + if ([value isKindOfClass:[NSString class]] && [(NSString *)value length] > 0 + && ![(NSString *)value isEqualToString:@"ordinaryMention"]) { + [chipRanges addObject:[NSValue valueWithRange:r]]; + } + }]; + for (NSValue *rv in chipRanges) { + NSRange r = rv.rangeValue; + NSString *chrome = [str attribute:KRSlockChromeAttributeName atIndex:r.location effectiveRange:NULL]; + if ([chrome isEqualToString:@"inlineCode"]) { + // The first/last atom bounds own inner padding + transparent outer + // margin. Never add a second kern reserve to the chain. + continue; + } + UIFont *font = [str attribute:NSFontAttributeName atIndex:r.location effectiveRange:NULL]; + CGFloat textSize = font ? font.pointSize : 15.0; + // box reserve = px-1 (4/15·textSize) + 1px border (mirror KRLabel.m). + // TRAILING only: reserve the box's right region so the next token (e.g. a comma + // with no source space) is pushed to the box edge, giving right-side inner + // padding. XiShi calibration (a510610f): a LEADING kern over-added the left + // external gap (17px) — the left gap should come from the source space alone, so + // no leading kern; the left inner padding is drawn by KRLayoutManager into the + // source space. + CGFloat boxReserve = textSize * (4.0 / 15.0) + 1.0; + [self p_addKern:boxReserve toString:str atIndex:NSMaxRange(r) - 1]; // trailing only + } +} + +- (void)p_addKern:(CGFloat)delta toString:(NSMutableAttributedString *)str atIndex:(NSUInteger)idx { + if (idx >= str.length) { + return; + } + NSNumber *existing = [str attribute:NSKernAttributeName atIndex:idx effectiveRange:NULL]; + CGFloat base = [existing isKindOfClass:[NSNumber class]] ? existing.doubleValue : 0.0; + [str addAttribute:NSKernAttributeName value:@(base + delta) range:NSMakeRange(idx, 1)]; +} + +- (nullable NSMutableAttributedString *)p_createSlockInlineCodeAtomChainWithAttributes:(KRSpanAttributes *)attrs { + if (attrs.text.length == 0) { + return nil; + } + NSMutableArray *graphemes = [NSMutableArray new]; + [attrs.text enumerateSubstringsInRange:NSMakeRange(0, attrs.text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + if (substring.length > 0 && ![substring isEqualToString:@"\u200B"]) { + [graphemes addObject:substring]; + } + }]; + if (graphemes.count == 0) { + return nil; + } + NSArray *atoms = graphemes.count <= kKRSlockInlineCodeAtomizeThreshold + ? @[ [graphemes componentsJoinedByString:@""] ] + : graphemes; + + NSMutableAttributedString *chain = [[NSMutableAttributedString alloc] init]; + [atoms enumerateObjectsUsingBlock:^(NSString *atomText, NSUInteger atomIndex, BOOL *stop) { + BOOL leadingEdge = atomIndex == 0; + BOOL trailingEdge = atomIndex == atoms.count - 1; + KRSlockInlineCodeAtomAttachment *attachment = [[KRSlockInlineCodeAtomAttachment alloc] + initWithText:atomText + font:attrs.font + textColor:attrs.color + letterSpacing:attrs.letterSpacing + leadingEdge:leadingEdge + trailingEdge:trailingEdge]; + NSMutableAttributedString *atom = [[NSMutableAttributedString alloc] + initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; + NSRange atomRange = NSMakeRange(0, atom.length); + [atom addAttribute:NSWritingDirectionAttributeName + value:@[@((NSInteger)NSWritingDirectionLeftToRight | (NSInteger)NSWritingDirectionOverride)] + range:atomRange]; + [atom addAttribute:NSFontAttributeName value:attrs.font ?: [UIFont systemFontOfSize:15.0] range:atomRange]; + [atom addAttribute:KRSlockChromeAttributeName value:@"inlineCode" range:atomRange]; + [atom addAttribute:KuiklyIndexAttributeName value:@(attrs.spanIndex) range:atomRange]; + [chain appendAttributedString:atom]; + }]; + NSRange chainRange = NSMakeRange(0, chain.length); + [self p_applyTextAttributeWithAttr:chain + textAliment:attrs.textAlign + lineSpacing:attrs.lineSpacing + paragraphSpacing:attrs.paragraphSpacing + lineHeight:attrs.lineHeight + range:chainRange + fontSize:attrs.font.pointSize + headIndent:attrs.headIndent + font:attrs.font ?: [UIFont systemFontOfSize:15.0]]; + return chain; +} + - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttributes:(KRSpanAttributes *)attrs { + if ([attrs.slockChrome isEqualToString:@"inlineCode"] && attrs.text.length > 0) { + return [self p_createSlockInlineCodeAtomChainWithAttributes:attrs]; + } + if (KRSlockUsesAtomicChipBox(attrs.slockChrome) && attrs.text.length > 0) { + KRSlockAtomicChipAttachment *attachment = [[KRSlockAtomicChipAttachment alloc] + initWithText:attrs.text + font:attrs.font + textColor:attrs.color + fillColor:KRSlockAtomicChipFillColor(attrs.slockChrome, attrs.backgroundColor) + letterSpacing:attrs.letterSpacing]; + NSMutableAttributedString *atomicBox = [[NSMutableAttributedString alloc] + initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; + NSRange atomicRange = NSMakeRange(0, atomicBox.length); + [atomicBox addAttribute:NSWritingDirectionAttributeName + value:@[@((NSInteger)NSWritingDirectionLeftToRight | (NSInteger)NSWritingDirectionOverride)] + range:atomicRange]; + [atomicBox addAttribute:NSFontAttributeName value:attrs.font ?: [UIFont systemFontOfSize:15.0] range:atomicRange]; + [atomicBox addAttribute:KuiklyIndexAttributeName value:@(attrs.spanIndex) range:atomicRange]; + NSLog(@"SLOCK_TASK448_ATOMIC kind=%@ text=\"%@\" bounds={%.2f,%.2f,%.2f,%.2f} spanIndex=%ld", + attrs.slockChrome, + attrs.text, + attachment.bounds.origin.x, + attachment.bounds.origin.y, + attachment.bounds.size.width, + attachment.bounds.size.height, + (long)attrs.spanIndex); + [self p_applyTextAttributeWithAttr:atomicBox + textAliment:attrs.textAlign + lineSpacing:attrs.lineSpacing + paragraphSpacing:attrs.paragraphSpacing + lineHeight:attrs.lineHeight + range:atomicRange + fontSize:attrs.font.pointSize + headIndent:attrs.headIndent + font:attrs.font ?: [UIFont systemFontOfSize:15.0]]; + return atomicBox; + } NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:attrs.text attributes:@{}]; NSRange range = NSMakeRange(0, attributedString.length); diff --git a/core-render-ios/Extension/TextSelection/KRTextSelectionHelper.m b/core-render-ios/Extension/TextSelection/KRTextSelectionHelper.m index da82f7620..9824f8224 100644 --- a/core-render-ios/Extension/TextSelection/KRTextSelectionHelper.m +++ b/core-render-ios/Extension/TextSelection/KRTextSelectionHelper.m @@ -20,6 +20,7 @@ #import "KRTextMagnifierView.h" #import "KRScrollView.h" #import "KRLogModule.h" +#import "KuiklyRenderBridge.h" #define KR_ANCHOR_TAG_LEFT 1001 #define KR_ANCHOR_TAG_RIGHT 1002 @@ -696,6 +697,47 @@ - (NSRange)rangeOfSentenceAtIndex:(NSInteger)index inString:(NSString *)string { #pragma mark - Public Methods +- (NSString *)kr_restoredTextForLabel:(KRLabel *)label range:(NSRange)range { + NSTextStorage *textStorage = label.textRender.textStorage; + if (!textStorage || range.location == NSNotFound || NSMaxRange(range) > textStorage.length) { + return @""; + } + NSMutableString *result = [NSMutableString string]; + __block NSUInteger cursor = range.location; + [textStorage enumerateAttribute:NSAttachmentAttributeName + inRange:range + options:0 + usingBlock:^(id value, NSRange attachmentRange, BOOL *stop) { + if (attachmentRange.location > cursor) { + [result appendString:[textStorage.string substringWithRange:NSMakeRange(cursor, attachmentRange.location - cursor)]]; + } + if ([value respondsToSelector:@selector(kr_originlTextBeforeTextAttachment)]) { + id attachment = (id)value; + [result appendString:[attachment kr_originlTextBeforeTextAttachment] ?: @""]; + } else { + [result appendString:[textStorage.string substringWithRange:attachmentRange]]; + } + cursor = NSMaxRange(attachmentRange); + }]; + if (cursor < NSMaxRange(range)) { + [result appendString:[textStorage.string substringWithRange:NSMakeRange(cursor, NSMaxRange(range) - cursor)]]; + } + return result; +} + +- (NSString *)kr_restoredTextFromIndex:(NSUInteger)index inLabel:(KRLabel *)label { + NSUInteger length = label.textRender.textStorage.length; + if (index >= length) { + return @""; + } + return [self kr_restoredTextForLabel:label range:NSMakeRange(index, length - index)]; +} + +- (NSString *)kr_restoredTextToIndex:(NSUInteger)index inLabel:(KRLabel *)label { + NSUInteger length = label.textRender.textStorage.length; + return [self kr_restoredTextForLabel:label range:NSMakeRange(0, MIN(index, length))]; +} + - (NSArray *)getSelectedTexts { if (!self.startLabel || !self.endLabel || self.startIndex < 0 || self.endIndex < 0) { return @[]; @@ -707,32 +749,28 @@ - (NSRange)rangeOfSentenceAtIndex:(NSInteger)index inString:(NSString *)string { for (KRLabel *label in self.labels) { if (label == self.startLabel && label == self.endLabel) { // Single label selection - NSString *text = label.textRender.textStorage.string; NSRange range = NSMakeRange(self.startIndex, self.endIndex - self.startIndex); - if (range.location + range.length <= text.length) { - [texts addObject:[text substringWithRange:range]]; + if (range.location + range.length <= label.textRender.textStorage.length) { + [texts addObject:[self kr_restoredTextForLabel:label range:range]]; } break; } else if (label == self.startLabel) { // Start of multi-label selection - NSString *text = label.textRender.textStorage.string; - if (self.startIndex < text.length) { - [texts addObject:[text substringFromIndex:self.startIndex]]; + if (self.startIndex < label.textRender.textStorage.length) { + [texts addObject:[self kr_restoredTextFromIndex:self.startIndex inLabel:label]]; } collecting = YES; } else if (label == self.endLabel) { // End of multi-label selection - NSString *text = label.textRender.textStorage.string; - if (self.endIndex <= text.length) { - [texts addObject:[text substringToIndex:self.endIndex]]; + if (self.endIndex <= label.textRender.textStorage.length) { + [texts addObject:[self kr_restoredTextToIndex:self.endIndex inLabel:label]]; } collecting = NO; break; } else if (collecting) { // Middle labels - select all text - NSString *text = label.textRender.textStorage.string; - if (text.length > 0) { - [texts addObject:text]; + if (label.textRender.textStorage.length > 0) { + [texts addObject:[self kr_restoredTextForLabel:label range:NSMakeRange(0, label.textRender.textStorage.length)]]; } } } @@ -756,15 +794,14 @@ - (NSRange)rangeOfSentenceAtIndex:(NSInteger)index inString:(NSString *)string { // Add previous label's text if exists if (startLabelIndex > 0) { KRLabel *previousLabel = self.labels[startLabelIndex - 1]; - NSString *previousText = previousLabel.textRender.textStorage.string; + NSString *previousText = [self kr_restoredTextForLabel:previousLabel range:NSMakeRange(0, previousLabel.textRender.textStorage.length)]; [preContent addObject:previousText ?: @""]; } // Add text before selection in start label // According to requirement b): if selection starts at index 0 (covers from beginning), this should be "" - NSString *startLabelText = self.startLabel.textRender.textStorage.string; - if (self.startIndex > 0 && self.startIndex <= startLabelText.length) { - [preContent addObject:[startLabelText substringToIndex:self.startIndex]]; + if (self.startIndex > 0 && self.startIndex <= self.startLabel.textRender.textStorage.length) { + [preContent addObject:[self kr_restoredTextToIndex:self.startIndex inLabel:self.startLabel]]; } else { // Selection starts at beginning, so preContent's last element is "" [preContent addObject:@""]; @@ -788,9 +825,8 @@ - (NSRange)rangeOfSentenceAtIndex:(NSInteger)index inString:(NSString *)string { // Add text after selection in end label // According to requirement b): if selection ends at end of text (covers to end), this should be "" - NSString *endLabelText = self.endLabel.textRender.textStorage.string; - if (self.endIndex < endLabelText.length) { - [postContent addObject:[endLabelText substringFromIndex:self.endIndex]]; + if (self.endIndex < self.endLabel.textRender.textStorage.length) { + [postContent addObject:[self kr_restoredTextFromIndex:self.endIndex inLabel:self.endLabel]]; } else { // Selection ends at end, so postContent's first element is "" [postContent addObject:@""]; @@ -799,7 +835,7 @@ - (NSRange)rangeOfSentenceAtIndex:(NSInteger)index inString:(NSString *)string { // Add next label's text if exists if (endLabelIndex < self.labels.count - 1) { KRLabel *nextLabel = self.labels[endLabelIndex + 1]; - NSString *nextText = nextLabel.textRender.textStorage.string; + NSString *nextText = [self kr_restoredTextForLabel:nextLabel range:NSMakeRange(0, nextLabel.textRender.textStorage.length)]; [postContent addObject:nextText ?: @""]; } @@ -1001,4 +1037,3 @@ - (void)removeContainerViewFrameObserver { } @end - diff --git a/core-render-ios/Extension/Vendor/KRLabel.h b/core-render-ios/Extension/Vendor/KRLabel.h index e8b5e1ad0..40a5ed72f 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.h +++ b/core-render-ios/Extension/Vendor/KRLabel.h @@ -29,6 +29,11 @@ extern NSString *const KRBGAttributeKey; // text SpanStyle / NSBackgroundColorAttributeName cannot express. extern NSString *const KRSlockChromeAttributeName; +@protocol KRSlockInlineCodeAtomProtocol +- (BOOL)kr_slockInlineCodeLeadingEdge; +- (BOOL)kr_slockInlineCodeTrailingEdge; +@end + @interface KRLabel : UILabel @@ -142,6 +147,3 @@ typedef NS_ENUM(NSUInteger, KRAttachmentAlignment) { @end NS_ASSUME_NONNULL_END - - - diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 32392aed0..7be23df39 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -19,6 +19,7 @@ #import "KRAsyncDeallocManager.h" #import #import "NSObject+KR.h" +#import "KuiklyRenderBridge.h" #define KRAssertMainThread() NSAssert(0 != pthread_main_np(), @"This method must be called on the main thread!") NSString *const KRHighlightAttributeKey = @"KRHighlightAttributeKey"; @@ -35,7 +36,7 @@ // (acceptance: fork grep finds no SLOCK constants). Do NOT let these become a new // long-term source of truth. // Fill colors: SlockRichTextChromeStyleTokens.InlineCode.chipFill etc. (ARGB). -static const uint32_t kKRSlockInlineCodeFillARGB = 0x66FFD84D; // InlineCode.chipFill (FFD84D @ 40%) +static const uint32_t kKRSlockInlineCodeFillARGB = 0x66FFD440; // react bg-soft-signal/40 = #FFD440 @ 40% (was 0x66FFD84D, the Android outlier — SlockMarkdown.kt:1485-90) static const uint32_t kKRSlockChannelFillARGB = 0x4DFE7DA8; // Channel.chipFill (pink @ 30%) static const uint32_t kKRSlockThreadFillARGB = 0x4D27CCF3; // Thread.chipFill (cyan @ 30%) static const uint32_t kKRSlockTaskFillARGB = 0x66FFD440; // Task.chipFill (yellow @ 40%) @@ -69,6 +70,33 @@ return [UIColor colorWithRed:r green:g blue:b alpha:a]; } +static NSString *KRRestoredTextAttachmentString(NSAttributedString *attributedString) { + if (attributedString.length == 0) { + return @""; + } + NSMutableString *result = [NSMutableString string]; + __block NSUInteger cursor = 0; + [attributedString enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (range.location > cursor) { + [result appendString:[attributedString.string substringWithRange:NSMakeRange(cursor, range.location - cursor)]]; + } + if ([value respondsToSelector:@selector(kr_originlTextBeforeTextAttachment)]) { + id attachment = (id)value; + [result appendString:[attachment kr_originlTextBeforeTextAttachment] ?: @""]; + } else { + [result appendString:[attributedString.string substringWithRange:range]]; + } + cursor = NSMaxRange(range); + }]; + if (cursor < attributedString.length) { + [result appendString:[attributedString.string substringWithRange:NSMakeRange(cursor, attributedString.length - cursor)]]; + } + return result; +} + @interface KRLabel() @@ -98,7 +126,7 @@ - (void)setSelectionColor:(UIColor *)selectionColor { - (NSString *)accessibilityLabel{ NSString * res = [super accessibilityLabel]; if (res.length <= 0) { - return self.attributedText.string; + return KRRestoredTextAttachmentString(self.attributedText); } return res; } @@ -641,21 +669,55 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat baseline = lineRect.origin.y + loc.y + origin.y; BOOL isRunStart = (segmentGlyphRange.location == runGlyphRange.location); BOOL isRunEnd = (NSMaxRange(segmentGlyphRange) >= runGlyphEnd); - CGFloat glyphLeft = CGRectGetMinX(gb) + origin.x; - CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; - // React MSG_REF_CHIP px-1: the fill is hPadding wider than the glyphs on each - // OUTER edge of the run; a wrap-continuation edge stays flush with the break. - CGFloat left = isRunStart ? (glyphLeft - hPadding) : glyphLeft; - CGFloat right = isRunEnd ? (glyphRight + hPadding) : glyphRight; + BOOL isInlineCode = [(NSString *)value isEqualToString:@"inlineCode"]; + if (isInlineCode && lineCharRange.length > 0) { + id firstAtom = [textStorage attribute:NSAttachmentAttributeName + atIndex:lineCharRange.location + effectiveRange:NULL]; + id lastAtom = [textStorage attribute:NSAttachmentAttributeName + atIndex:NSMaxRange(lineCharRange) - 1 + effectiveRange:NULL]; + isRunStart = [firstAtom respondsToSelector:@selector(kr_slockInlineCodeLeadingEdge)] && + [(id)firstAtom kr_slockInlineCodeLeadingEdge]; + isRunEnd = [lastAtom respondsToSelector:@selector(kr_slockInlineCodeTrailingEdge)] && + [(id)lastAtom kr_slockInlineCodeTrailingEdge]; + } + CGFloat left; + CGFloat right; + if (isInlineCode) { + // Atom bounds already include 4/15 inner padding + 2/15 outer + // margin at the global span edges. Paint the final line-fragment + // chain only after TextKit wrapping, trimming the transparent + // outer margin while keeping the inner padding inside chrome. + CGFloat outerMargin = textSize * (2.0 / 15.0); + left = CGRectGetMinX(gb) + origin.x + (isRunStart ? outerMargin : 0.0); + right = CGRectGetMaxX(gb) + origin.x - (isRunEnd ? outerMargin : 0.0); + } else { + // Legacy non-atomic chrome fallback. + CGFloat glyphLeft = lineRect.origin.x + loc.x + origin.x; + CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; + CGFloat boxReserve = hPadding; + left = isRunStart ? (glyphLeft - boxReserve) : glyphLeft; + right = isRunEnd ? (glyphRight + boxReserve) : glyphRight; + } if (right <= left) { return; } - // React leading-[1.5]: a 1.5·fontSize tall box centered on the font's vertical - // center (baseline - (ascender+descender)/2) so the glyph is centered with - // symmetric top/bottom padding (any residual low-sit is the systemic baseline). - CGFloat centerY = baseline - (ascender + descender) / 2.0; - CGFloat top = centerY - chipHeight / 2.0; - CGFloat bottom = centerY + chipHeight / 2.0; + CGFloat top; + CGFloat bottom; + if (isInlineCode) { + // The atom attachment already owns the exact 1.5x box height and + // centers its glyph image inside that box. Reuse TextKit's final + // attachment bounds for chrome so measurement, glyph baseline, + // fill and border all share one vertical coordinate system. + top = CGRectGetMinY(gb) + origin.y; + bottom = CGRectGetMaxY(gb) + origin.y; + } else { + // Legacy glyph-flow chrome: center a 1.5x box on font metrics. + CGFloat centerY = baseline - (ascender + descender) / 2.0; + top = centerY - chipHeight / 2.0; + bottom = centerY + chipHeight / 2.0; + } if (bottom <= top) { return; } @@ -672,8 +734,16 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGContextSetFillColorWithColor(ctx, [UIColor blackColor].CGColor); CGContextFillRect(ctx, CGRectMake(bl, bt, br - bl, bw)); CGContextFillRect(ctx, CGRectMake(bl, bb - bw, br - bl, bw)); - CGContextFillRect(ctx, CGRectMake(bl, bt, bw, bb - bt)); - CGContextFillRect(ctx, CGRectMake(br - bw, bt, bw, bb - bt)); + // A wrapping inline-code chain has only two semantic side edges: + // the global span start and end. Line-wrap boundaries are internal + // atom joins; drawing vertical borders there was the old experiment's + // clipping bug (continuation first glyph sat under a pre-drawn edge). + if (!isInlineCode || isRunStart) { + CGContextFillRect(ctx, CGRectMake(bl, bt, bw, bb - bt)); + } + if (!isInlineCode || isRunEnd) { + CGContextFillRect(ctx, CGRectMake(br - bw, bt, bw, bb - bt)); + } }]; }]; } @@ -825,5 +895,3 @@ - (void)setHr_size:(CGSize)hr_size{ objc_setAssociatedObject(self, @selector(hr_size), [NSValue valueWithCGSize:hr_size], OBJC_ASSOCIATION_RETAIN); } @end - - From 1888e63737e5dc08b973e29d1f20ce4d91d35660 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 06:20:33 +0800 Subject: [PATCH 084/187] fix(render): bridge marshal never drops fields silently (mobile task #476) (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(render): bridge marshal never drops fields silently (mobile task #476) Map.toJSONObject()/List.toJSONArray() had a closed when with no else: any value outside the eight known types — org.json.JSONObject/JSONArray above all — was silently skipped, the key vanished, and the bridge reported ok (root cause of mobile #484's lost diagnostics entries; same silent-drop family as OHOS KNOI's null handling). - Already-JSON values now pass straight through (they need no conversion; the natural nested-payload case). - Unsupported types are still unrepresentable, but dropped LOUDLY via KuiklyRenderLog.e — on the app side e() persists in the diagnostics ring, so a vanishing field now leaves evidence. - null keeps the long-standing absent-key contract (deliberate, documented in-code; matches the KNOI absence semantics on OHOS). KuiklyRenderExtensionMarshalTest pins all of it (pass-through, null contract, loud drop without corrupting siblings, recursion, array mirror); real org.json added for JVM unit tests. Co-Authored-By: Claude Fable 5 * fix(render): survive erased-cast nulls in toJSONArray's loud-drop branch Codex's review edge: List can smuggle nulls via erased Java casts; value.javaClass in the else branch would NPE. Null elements skip safely (absent semantics) with the type logged as "null". Pinned by arrayMarshalSurvivesErasedCastNulls. Co-Authored-By: Claude Fable 5 * fix(render): null stays silent in toJSONArray — absence never reaches the loud path Codex's semantic blocker (LiBai-endorsed): the erased-null fix still routed null through the else branch as 'unsupported type=null' — once the app persists e() logs, every legal absent element would become persistent error noise, silently flipping null's observability contract. Explicit null branch before else: absence skips silently (matching toJSONObject's null branch and the confirmed contract). Tests now pin BOTH semantics through a recording IKRLogAdapter: null never invokes the error adapter; unknown types invoke it exactly once with stable tag + key + type. Co-Authored-By: Claude Fable 5 * refactor(render): List receiver makes the null branch honestly typed LiBai's non-blocking nit: the erased-null branch was statically unreachable under List. Widening the receiver (call sites unchanged — List flows in covariantly) removes the warning and types the absence contract explicitly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: CC-Cata Co-authored-by: Claude Fable 5 --- core-render-android/build.2.1.21.gradle.kts | 3 + .../android/css/ktx/KuiklyRenderExtension.kt | 43 ++++- .../ktx/KuiklyRenderExtensionMarshalTest.kt | 168 ++++++++++++++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtensionMarshalTest.kt diff --git a/core-render-android/build.2.1.21.gradle.kts b/core-render-android/build.2.1.21.gradle.kts index 1bad607ef..a73b4f809 100644 --- a/core-render-android/build.2.1.21.gradle.kts +++ b/core-render-android/build.2.1.21.gradle.kts @@ -80,4 +80,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.2.0") implementation("androidx.dynamicanimation:dynamicanimation:1.0.0") testImplementation("junit:junit:4.13.2") + // task #476: real org.json for JVM unit tests (the android.jar stubs + // throw "not mocked") — needed by KuiklyRenderExtensionMarshalTest. + testImplementation("org.json:json:20231013") } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtension.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtension.kt index 2d1637c18..9ea19db33 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtension.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtension.kt @@ -271,6 +271,28 @@ internal fun Map.toJSONObject(): JSONObject { val list = value as List serializationObject.put(key, list.toJSONArray()) } + // task #476: already-JSON values pass straight through. They + // used to fall out of this when silently — the key vanished + // while the bridge reported ok (mobile #484's root cause). + is JSONObject -> { + serializationObject.put(key, value) + } + is JSONArray -> { + serializationObject.put(key, value) + } + null -> { + // Deliberately silent: absent-key-for-null is the + // long-standing cross-bridge absence contract (same as + // KNOI on OHOS). Callers model absence by omitting keys. + } + else -> { + // task #476 fail-loud: an unrepresentable value still + // cannot cross, but it must never vanish silently again. + KuiklyRenderLog.e( + "KuiklyRenderExtension", + "toJSONObject dropped unsupported value: key=$key type=${value.javaClass.name}" + ) + } } } } @@ -281,7 +303,7 @@ internal fun Map.toJSONObject(): JSONObject { * [List]转[JSONArray] */ @Suppress("UNCHECKED_CAST") -internal fun List.toJSONArray(): JSONArray { +internal fun List.toJSONArray(): JSONArray { val serializationArray = JSONArray() forEach { value -> when (value) { @@ -311,6 +333,25 @@ internal fun List.toJSONArray(): JSONArray { val list = value as List serializationArray.put(list.toJSONArray()) } + // task #476: mirror toJSONObject — pass JSON values through, + // never drop an element silently. + is JSONObject -> { + serializationArray.put(value) + } + is JSONArray -> { + serializationArray.put(value) + } + // Null is ABSENCE, not an unsupported type: it skips silently + // (same contract as toJSONObject's null branch) and must never + // reach the loud path — once the app persists e() logs, a loud + // null would turn every legal absent element into noise. + null -> Unit + else -> { + KuiklyRenderLog.e( + "KuiklyRenderExtension", + "toJSONArray dropped unsupported element: type=${value.javaClass.name}" + ) + } } } return serializationArray diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtensionMarshalTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtensionMarshalTest.kt new file mode 100644 index 000000000..27684a385 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KuiklyRenderExtensionMarshalTest.kt @@ -0,0 +1,168 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.css.ktx + +import com.tencent.kuikly.core.render.android.adapter.IKRLogAdapter +import com.tencent.kuikly.core.render.android.adapter.KuiklyRenderAdapterManager +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * task #476: values the bridge cannot represent must never vanish silently. + * Pins the marshal contract of [toJSONObject]/[toJSONArray]: + * already-JSON values pass through, null keeps the absent-key contract, + * and unsupported types are dropped loudly (logged) without corrupting + * the rest of the payload. + */ +class KuiklyRenderExtensionMarshalTest { + + private class RecordingLogAdapter : IKRLogAdapter { + override val asyncLogEnable: Boolean = false + val errors = mutableListOf>() + override fun i(tag: String, msg: String) = Unit + override fun d(tag: String, msg: String) = Unit + override fun e(tag: String, msg: String) { + errors.add(tag to msg) + } + } + + private val recordingAdapter = RecordingLogAdapter() + + @Before + fun installRecordingAdapter() { + KuiklyRenderAdapterManager.krLogAdapter = recordingAdapter + } + + @After + fun removeRecordingAdapter() { + KuiklyRenderAdapterManager.krLogAdapter = null + } + + @Test + fun jsonObjectAndArrayValuesPassThrough() { + val nestedArray = JSONArray().put("a").put(1) + val nestedObject = JSONObject().put("k", "v") + val result = mapOf( + "status" to "ok", + "entries" to nestedArray, + "meta" to nestedObject + ).toJSONObject() + + assertEquals("ok", result.getString("status")) + // The #484 bug shape: these two keys used to vanish. + assertEquals(nestedArray.toString(), result.getJSONArray("entries").toString()) + assertEquals(nestedObject.toString(), result.getJSONObject("meta").toString()) + } + + @Test + fun nullValuesKeepTheAbsentKeyContract() { + val result = mapOf( + "present" to 1, + "absent" to null + ).toJSONObject() + + assertEquals(1, result.getInt("present")) + assertFalse(result.has("absent")) + } + + @Test + fun unsupportedValueIsDroppedWithoutCorruptingSiblings() { + val result = mapOf( + "good" to "value", + "bad" to Any(), + "alsoGood" to true + ).toJSONObject() + + assertEquals("value", result.getString("good")) + assertTrue(result.getBoolean("alsoGood")) + assertFalse(result.has("bad")) + } + + @Test + fun nestedContainersStillRecurse() { + val result = mapOf( + "map" to mapOf("inner" to 2), + "list" to listOf("x", mapOf("y" to 3)) + ).toJSONObject() + + assertEquals(2, result.getJSONObject("map").getInt("inner")) + val list = result.getJSONArray("list") + assertEquals("x", list.getString(0)) + assertEquals(3, list.getJSONObject(1).getInt("y")) + } + + @Test + fun arrayMarshalSurvivesErasedCastNullsSilently() { + // List by declaration, but erased casts from Java can smuggle + // nulls — absence stays SILENT: skipped, no crash, and the error + // adapter is never invoked (a loud null would flood the persistent + // diagnostics ring once the app wires e() into it). + @Suppress("UNCHECKED_CAST") + val listWithNull = listOf("a", null, "b") as List + val result = listWithNull.toJSONArray() + + assertEquals(2, result.length()) + assertEquals("a", result.getString(0)) + assertEquals("b", result.getString(1)) + assertTrue("null must not reach the error adapter", recordingAdapter.errors.isEmpty()) + } + + @Test + fun unsupportedMapValueLogsExactlyOnceWithKeyAndType() { + val result = mapOf( + "good" to 1, + "bad" to Any() + ).toJSONObject() + + assertEquals(1, result.getInt("good")) + assertFalse(result.has("bad")) + assertEquals(1, recordingAdapter.errors.size) + val (tag, msg) = recordingAdapter.errors.single() + assertEquals("KuiklyRenderExtension", tag) + assertTrue("message must carry the key", msg.contains("key=bad")) + assertTrue("message must carry the type", msg.contains("java.lang.Object")) + } + + @Test + fun unsupportedListElementLogsExactlyOnceWithType() { + val result = listOf("keep", Any()).toJSONArray() + + assertEquals(1, result.length()) + assertEquals(1, recordingAdapter.errors.size) + val (tag, msg) = recordingAdapter.errors.single() + assertEquals("KuiklyRenderExtension", tag) + assertTrue("message must carry the type", msg.contains("java.lang.Object")) + } + + @Test + fun arrayMarshalPassesJsonThroughAndDropsUnsupportedLoudly() { + val nested = JSONObject().put("id", 7) + val result = listOf("s", 1, nested, Any(), JSONArray().put(false)).toJSONArray() + + // Unsupported Any() is dropped; everything else survives in order. + assertEquals(4, result.length()) + assertEquals("s", result.getString(0)) + assertEquals(1, result.getInt(1)) + assertEquals(nested.toString(), result.getJSONObject(2).toString()) + assertEquals(false, result.getJSONArray(3).getBoolean(0)) + } +} From 33fa419bcb6fe9d83bb5a469b87561b1f0b38079 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 12:46:19 +0800 Subject: [PATCH 085/187] fix(android): keep ordinary mentions underlined (#14) Signed-off-by: Codex-KMP-Developer Co-authored-by: Codex-KMP-Developer --- .../component/text/KRRichTextBuilder.kt | 10 ++++++++- .../component/text/KRRichTextViewDrawer.kt | 1 + .../text/KRSlockMarkdownTagChromeTest.kt | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 4cdf9e887..63ecee4f0 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -125,7 +125,10 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps is TextSpanProps && spanProps.slockInlineCode) { spannedBuilder.applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) } - if (spanProps is TextSpanProps && spanProps.slockMarkdownTagChrome != null) { + if ( + spanProps is TextSpanProps && + spanProps.slockMarkdownTagChrome.isSlockMarkdownTagChipChrome() + ) { spannedBuilder.applySlockMarkdownTagAtomicTextSpan(spanStart, spanEnd) } } @@ -416,6 +419,11 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { class KRSlockInlineCodeSpan class KRSlockMarkdownTagSpan(val kind: String) +internal const val SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION = "ordinaryMention" + +internal fun String?.isSlockMarkdownTagChipChrome(): Boolean = + this != null && this != SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION + private class KRSlockInlineCodeTrailingMarginSpan : ReplacementSpan() { override fun getSize( diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 06a07e51d..cf8337c17 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -134,6 +134,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val layoutRight = textLayout.width.toFloat() spans.forEach { span -> + if (!span.kind.isSlockMarkdownTagChipChrome()) return@forEach val start = spanned.getSpanStart(span) val end = spanned.getSpanEnd(span) if (start < 0 || end <= start) return@forEach diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt new file mode 100644 index 000000000..5cc8782aa --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt @@ -0,0 +1,21 @@ +package com.tencent.kuikly.core.render.android.expand.component.text + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KRSlockMarkdownTagChromeTest { + + @Test + fun ordinaryMentionUsesTextUnderlineInsteadOfAtomicChipChrome() { + assertFalse(SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION.isSlockMarkdownTagChipChrome()) + assertFalse((null as String?).isSlockMarkdownTagChipChrome()) + } + + @Test + fun actualChipKindsKeepAtomicLayoutAndPaintChrome() { + listOf("channel", "thread", "task", "selfMention", "active").forEach { kind -> + assertTrue(kind, kind.isSlockMarkdownTagChipChrome()) + } + } +} From 7602d0e1775bb759d3969eea6e3e1359245815d6 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 13:44:58 +0800 Subject: [PATCH 086/187] ohos(task #414): render markdown chips with native box geometry (#13) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../components/richtext/KRRichTextShadow.cpp | 230 +++++++++++++++++- .../components/richtext/KRRichTextShadow.h | 24 +- .../components/richtext/KRRichTextView.cpp | 129 ++++++++++ 3 files changed, 371 insertions(+), 12 deletions(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp index deea4fae6..a411a086f 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp @@ -28,7 +28,9 @@ #include #include +#include #include +#include #include #include @@ -65,6 +67,80 @@ template struct deletable_facet : Facet { constexpr char kRawFilePrefix[] = "rawfile:"; +namespace { + +constexpr char16_t kSlockNonBreakingSpace = u'\u00A0'; +constexpr char16_t kSlockZeroWidthBreak = u'\u200B'; +constexpr char16_t kObjectReplacementCharacter = u'\uFFFC'; +constexpr float kSlockInnerPaddingRatio = 4.0f / 15.0f; +constexpr float kSlockOuterMarginRatio = 2.0f / 15.0f; +constexpr float kSlockTrailingMarginRatio = 1.0f / 15.0f; +constexpr float kSlockChipLineHeightRatio = 1.5f; + +std::u16string KRUtf8ToUtf16(const std::string &text) { + std::wstring_convert, char16_t> converter; + return converter.from_bytes(text); +} + +std::string KRUtf16ToUtf8(const std::u16string &text) { + std::wstring_convert, char16_t> converter; + return converter.to_bytes(text); +} + +struct KRSlockInlineCodeTextPlan { + std::u16string layout_text; + std::u16string semantic_text; + std::vector layout_to_semantic_offsets{0}; +}; + +KRSlockInlineCodeTextPlan KRBuildSlockInlineCodeTextPlan(const std::string &text) { + const std::u16string input = KRUtf8ToUtf16(text); + size_t begin = 0; + size_t end = input.size(); + // The shared OHOS bridge currently wraps inline code in NBSP. Native chrome owns + // its edge geometry, so consume (do not render) those bridge-only sentinels here. + // Consume exactly one sentinel on each edge. If the source itself begins or + // ends with NBSP, the shared bridge emits two and the source unit must remain. + if (begin < end && input[begin] == kSlockNonBreakingSpace) { + ++begin; + } + if (end > begin && input[end - 1] == kSlockNonBreakingSpace) { + --end; + } + + KRSlockInlineCodeTextPlan result; + for (size_t i = begin; i < end; ++i) { + const char16_t code_unit = input[i]; + result.layout_text.push_back(code_unit); + if (code_unit != kSlockZeroWidthBreak) { + result.semantic_text.push_back(code_unit); + } + result.layout_to_semantic_offsets.push_back(result.semantic_text.size()); + } + return result; +} + +uint32_t KRSlockChromeFillColor(const std::string &kind) { + if (kind == "inlineCode") { + return 0x66FFD440; + } + if (kind == "channel") { + return 0x4DFE7DA8; + } + if (kind == "thread") { + return 0x4D27CCF3; + } + if (kind == "task") { + return 0x66FFD440; + } + if (kind == "selfMention" || kind == "active") { + return 0xFFFFD440; + } + return 0; +} + +} // namespace + static bool isRawFilePath(const std::string &src) { return src.find(kRawFilePrefix) == 0; } @@ -114,6 +190,26 @@ KRAnyValue KRRichTextShadow::Call(const std::string &method_name, const std::str return KRRenderValue::Make(nullptr); } +std::string KRRichTextShadow::SemanticSelection(int layout_start, int layout_end, std::string &pre, + std::string &post) const { + const std::u16string semantic = KRUtf8ToUtf16(main_thread_semantic_text_content_); + const auto &offsets = main_thread_layout_to_semantic_offsets_; + if (offsets.empty()) { + pre.clear(); + post.clear(); + return main_thread_semantic_text_content_; + } + + const size_t clamped_start = std::min(static_cast(std::max(layout_start, 0)), offsets.size() - 1); + const size_t clamped_end = std::min(static_cast(std::max(layout_end, 0)), offsets.size() - 1); + const size_t semantic_start = std::min(offsets[std::min(clamped_start, clamped_end)], semantic.size()); + const size_t semantic_end = std::min(offsets[std::max(clamped_start, clamped_end)], semantic.size()); + + pre = KRUtf16ToUtf8(semantic.substr(0, semantic_start)); + post = KRUtf16ToUtf8(semantic.substr(semantic_end)); + return KRUtf16ToUtf8(semantic.substr(semantic_start, semantic_end - semantic_start)); +} + /** * 根据布局约束尺寸计算返回 RenderView 的实际尺寸 * @param constraint_width @@ -199,13 +295,22 @@ KRSchedulerTask KRRichTextShadow::TaskToMainQueueWhenWillSetShadowToView() { auto offsetX = context_thread_drawOffsetX_; auto measure_size = context_measure_size_; auto text_align = context_thread_text_align_; - return [self, typography, offsetY, offsetX, measure_size, text_align] { + auto text_content = context_thread_text_content_; + auto semantic_text_content = context_thread_semantic_text_content_; + auto layout_to_semantic_offsets = context_thread_layout_to_semantic_offsets_; + auto slock_chrome_runs = context_thread_slock_chrome_runs_; + return [self, typography, offsetY, offsetX, measure_size, text_align, text_content, + semantic_text_content, layout_to_semantic_offsets, slock_chrome_runs] { KRRichTextShadow *shadow = reinterpret_cast(self.get()); shadow->SetMainThreadTypography(typography); shadow->main_thread_drawOffsetY_ = offsetY; shadow->main_thread_drawOffsetX_ = offsetX; shadow->main_thread_text_align_ = text_align; shadow->main_measure_size_ = measure_size; + shadow->main_thread_text_content_ = text_content; + shadow->main_thread_semantic_text_content_ = semantic_text_content; + shadow->main_thread_layout_to_semantic_offsets_ = layout_to_semantic_offsets; + shadow->main_thread_slock_chrome_runs_ = slock_chrome_runs; }; } @@ -330,6 +435,10 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w span_offsets_.clear(); placeholder_index_map_.clear(); image_draw_records_.clear(); + context_thread_slock_chrome_runs_.clear(); + context_thread_layout_to_semantic_offsets_.clear(); + context_thread_text_content_.clear(); + context_thread_semantic_text_content_.clear(); KRRenderValue::Array spans = values_; if (spans.empty()) { spans.push_back(KRRenderValue::Make(props_)); @@ -431,7 +540,29 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w int placeholder_count = 0; OH_Drawing_TextAlign text_align = TEXT_ALIGN_LEFT; int charOffset = 0; - std::string text_content; + std::u16string layout_text_content; + std::u16string semantic_text_content; + std::vector layout_to_semantic_offsets{0}; + auto append_mapped_text = [&](const std::u16string &layout_text, const std::u16string &semantic_text, + const std::vector *local_offsets) { + const size_t semantic_base = semantic_text_content.size(); + layout_text_content.append(layout_text); + semantic_text_content.append(semantic_text); + if (local_offsets && local_offsets->size() == layout_text.size() + 1) { + for (size_t i = 1; i < local_offsets->size(); ++i) { + layout_to_semantic_offsets.push_back(semantic_base + (*local_offsets)[i]); + } + } else { + for (size_t i = 1; i <= layout_text.size(); ++i) { + layout_to_semantic_offsets.push_back(semantic_base + std::min(i, semantic_text.size())); + } + } + }; + auto append_placeholder_mapping = [&](const std::u16string &semantic_text) { + layout_text_content.push_back(kObjectReplacementCharacter); + semantic_text_content.append(semantic_text); + layout_to_semantic_offsets.push_back(semantic_text_content.size()); + }; for (auto span : spans) { auto spanMap = span->toMap(); auto fontSize = (GetKRValue("fontSize", spanMap, props_)->toFloat() ?: 15.0) * dpi * fontSizeScale; @@ -464,6 +595,18 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w auto strokeWidth = GetKRValue("strokeWidth", spanMap, props_)->toFloat(); auto strokeColorStr = GetKRValue("strokeColor", spanMap, props_)->toString(); auto strokeColor = strokeColorStr.length() ? kuikly::util::ConvertToHexColor(strokeColorStr) : 0xff000000; + + const bool slockInlineCode = GetKRValue("slockInlineCode", spanMap, spanMap)->toBool(); + const bool slockInlineCodeTrailingMargin = + GetKRValue("slockInlineCodeTrailingMargin", spanMap, spanMap)->toBool(); + const std::string slockTagChrome = + GetKRValue("slockMarkdownTagChrome", spanMap, spanMap)->toString(); + const std::string slockChromeKind = slockInlineCode ? "inlineCode" : slockTagChrome; + const uint32_t slockFillColor = KRSlockChromeFillColor(slockChromeKind); + const bool isSlockChip = slockFillColor != 0; + if (isSlockChip) { + textDecoration = TEXT_DECORATION_NONE; + } auto placeholderWidth = GetKRValue("placeholderWidth", spanMap, spanMap)->toDouble(); // 创建文本样式对象txtStyle @@ -473,7 +616,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_Brush *textBackgroundBrush = nullptr; // 设置文字大小、字重等属性设置到文本样式对象中 OH_Drawing_SetTextStyleColor(txtStyle, color); - if (backgroundColorStr.length() && backgroundColor != 0x00000000) { + if (!isSlockChip && backgroundColorStr.length() && backgroundColor != 0x00000000) { textBackgroundBrush = OH_Drawing_BrushCreate(); OH_Drawing_BrushSetColor(textBackgroundBrush, backgroundColor); OH_Drawing_SetTextStyleBackgroundBrush(txtStyle, textBackgroundBrush); @@ -649,15 +792,76 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w } placeholder_count++; charOffset += 1; + append_placeholder_mapping({}); + } else if (slockInlineCodeTrailingMargin) { + // Android's KRSlockInlineCodeTrailingMarginSpan contract: the source + // space remains semantic text, while layout uses a 1/15 transparent + // advance instead of painting a visible whitespace glyph. + OH_Drawing_PlaceholderSpan trailingMargin = { + fontSize * kSlockTrailingMarginRatio, + fontSize * kSlockChipLineHeightRatio, + ALIGNMENT_CENTER_OF_ROW_BOX, + TEXT_BASELINE_ALPHABETIC, + 0, + }; + const int spanStart = charOffset; + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &trailingMargin); + placeholder_count++; + charOffset += 1; + append_placeholder_mapping(KRUtf8ToUtf16(text)); + span_offsets_.emplace_back(std::tuple(spanIndex, spanStart, charOffset)); + } else if (isSlockChip) { + const float edgeAdvance = + fontSize * (kSlockInnerPaddingRatio + kSlockOuterMarginRatio); + OH_Drawing_PlaceholderSpan edgePlaceholder = { + edgeAdvance, + fontSize * kSlockChipLineHeightRatio, + ALIGNMENT_CENTER_OF_ROW_BOX, + TEXT_BASELINE_ALPHABETIC, + 0, + }; + const int spanStart = charOffset; + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &edgePlaceholder); + placeholder_count++; + charOffset += 1; + append_placeholder_mapping({}); + + const int chromeStart = charOffset; + if (slockInlineCode) { + const auto plan = KRBuildSlockInlineCodeTextPlan(text); + const std::string layoutText = KRUtf16ToUtf8(plan.layout_text); + if (!layoutText.empty()) { + OH_Drawing_TypographyHandlerAddText(handler, layoutText.c_str()); + charOffset += static_cast(plan.layout_text.size()); + append_mapped_text(plan.layout_text, plan.semantic_text, + &plan.layout_to_semantic_offsets); + } + } else { + const std::u16string text16 = KRUtf8ToUtf16(text); + if (!text.empty()) { + OH_Drawing_TypographyHandlerAddText(handler, text.c_str()); + charOffset += static_cast(text16.size()); + append_mapped_text(text16, text16, nullptr); + } + } + const int chromeEnd = charOffset; + if (chromeEnd > chromeStart) { + context_thread_slock_chrome_runs_.push_back( + KRSlockChromeRun{chromeStart, chromeEnd, slockFillColor, static_cast(fontSize)}); + } + + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &edgePlaceholder); + placeholder_count++; + charOffset += 1; + append_placeholder_mapping({}); + span_offsets_.emplace_back(std::tuple(spanIndex, spanStart, charOffset)); } else { OH_Drawing_TypographyHandlerAddText(handler, text.c_str()); // 添加文本 - text_content.append(text); - - std::wstring_convert>, char16_t> conv16; - std::u16string str16 = conv16.from_bytes(text); - int codePointCount = str16.size(); + const std::u16string text16 = KRUtf8ToUtf16(text); + const int codePointCount = static_cast(text16.size()); span_offsets_.emplace_back(std::tuple(spanIndex, charOffset, charOffset + codePointCount)); charOffset += codePointCount; + append_mapped_text(text16, text16, nullptr); } OH_Drawing_DestroyTextStyle(txtStyle); if (textForegroundPen) { @@ -699,7 +903,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w #ifndef NDEBUG if (ouput_measure_width_ < 0.01) { KR_LOG_ERROR << "Measure size:" << ouput_measure_width_ << ", " << ouput_measure_height_ - << ", content bytes:" << GetTextContent().size() << ", in shadow view:" << this; + << ", content bytes:" << layout_text_content.size() << ", in shadow view:" << this; } #endif context_measure_size_ = KRSize(ouput_measure_width_, ouput_measure_height_); @@ -709,7 +913,9 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w if (typoStyle != nullptr) { OH_Drawing_DestroyTypographyStyle(typoStyle); } - text_content_ = text_content; + context_thread_text_content_ = KRUtf16ToUtf8(layout_text_content); + context_thread_semantic_text_content_ = KRUtf16ToUtf8(semantic_text_content); + context_thread_layout_to_semantic_offsets_ = std::move(layout_to_semantic_offsets); // 触发 image span 异步预加载(决策 3C)。当 image_draw_records_ 为空(业务未注册 // PostProcessor / 全是文本)时本方法立即返回,零开销。 TriggerImagePrefetchIfNeed(); @@ -729,6 +935,10 @@ void KRRichTextShadow::ReleaseLastTypography() { context_thread_drawOffsetX_ = 0; context_thread_text_align_ = TEXT_ALIGN_LEFT; context_measure_size_ = KRSize(0, 0); + context_thread_text_content_.clear(); + context_thread_semantic_text_content_.clear(); + context_thread_layout_to_semantic_offsets_.clear(); + context_thread_slock_chrome_runs_.clear(); } // ===== Phase 3: image span 异步预加载(委托 KRCustomEmojiPixmapCache) ===== diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h index 2173390aa..aeac85503 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h @@ -63,6 +63,13 @@ constexpr bool KR_TEXT_RENDER_V2_ENABLED = false; */ using KRTypographyHandle = std::shared_ptr; +struct KRSlockChromeRun { + int start = 0; + int end = 0; + uint32_t fill_color = 0; + float font_size_px = 0; +}; + inline KRTypographyHandle KRMakeTypographyHandle(OH_Drawing_Typography *raw) { if (raw == nullptr) { return KRTypographyHandle(); @@ -183,9 +190,15 @@ class KRRichTextShadow : public IKRRenderShadowExport { } std::string GetTextContent() const { - return text_content_; + return main_thread_text_content_; } + const std::vector &SlockChromeRuns() const { + return main_thread_slock_chrome_runs_; + } + + std::string SemanticSelection(int layout_start, int layout_end, std::string &pre, std::string &post) const; + KRSize MainMeasureSize() { return main_measure_size_; } @@ -272,7 +285,14 @@ class KRRichTextShadow : public IKRRenderShadowExport { // 通知 view markDirty。shadow 销毁时 weak_from_this 自动断链。 void TriggerImagePrefetchIfNeed(); private: - std::string text_content_; + std::string context_thread_text_content_; + std::string main_thread_text_content_; + std::string context_thread_semantic_text_content_; + std::string main_thread_semantic_text_content_; + std::vector context_thread_layout_to_semantic_offsets_; + std::vector main_thread_layout_to_semantic_offsets_; + std::vector context_thread_slock_chrome_runs_; + std::vector main_thread_slock_chrome_runs_; KRRenderValue::Map props_; KRRenderValue::Array values_; OH_Drawing_Array *text_lines_ = nullptr; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index f74555b2a..8c3894dc1 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -15,6 +15,8 @@ #include "libohos_render/expand/components/richtext/KRRichTextView.h" +#include +#include #include #include #include @@ -53,6 +55,121 @@ extern size_t OH_Drawing_GetEndFromRange(OH_Drawing_Range* range) __attribute__( } #endif +namespace { + +constexpr float kSlockChipInnerPaddingRatio = 4.0f / 15.0f; +constexpr float kSlockChipLineHeightRatio = 1.5f; +constexpr float kSlockChipBorderWidthVp = 1.0f; + +struct KRSlockChromeFragment { + float left = 0; + float top = 0; + float right = 0; + float bottom = 0; +}; + +void KRDrawBrushRect(OH_Drawing_Canvas *canvas, float left, float top, float right, float bottom) { + if (!canvas || right <= left || bottom <= top) { + return; + } + OH_Drawing_Rect *rect = OH_Drawing_RectCreate(left, top, right, bottom); + OH_Drawing_CanvasDrawRect(canvas, rect); + OH_Drawing_RectDestroy(rect); +} + +std::vector KRCollectSlockChromeFragments(OH_Drawing_Typography *typography, + const KRSlockChromeRun &run) { + std::vector fragments; + if (!typography || run.end <= run.start) { + return fragments; + } + OH_Drawing_TextBox *boxes = OH_Drawing_TypographyGetRectsForRange( + typography, run.start, run.end, RECT_HEIGHT_STYLE_MAX, RECT_WIDTH_STYLE_TIGHT); + if (!boxes) { + return fragments; + } + const int count = OH_Drawing_GetSizeOfTextBox(boxes); + for (int i = 0; i < count; ++i) { + KRSlockChromeFragment next{ + OH_Drawing_GetLeftFromTextBox(boxes, i), + OH_Drawing_GetTopFromTextBox(boxes, i), + OH_Drawing_GetRightFromTextBox(boxes, i), + OH_Drawing_GetBottomFromTextBox(boxes, i), + }; + if (!fragments.empty()) { + auto &last = fragments.back(); + const float lastCenter = (last.top + last.bottom) / 2.0f; + const float nextCenter = (next.top + next.bottom) / 2.0f; + if (std::fabs(lastCenter - nextCenter) <= 1.0f) { + last.left = std::min(last.left, next.left); + last.top = std::min(last.top, next.top); + last.right = std::max(last.right, next.right); + last.bottom = std::max(last.bottom, next.bottom); + continue; + } + } + fragments.push_back(next); + } + OH_Drawing_TypographyDestroyTextBox(boxes); + return fragments; +} + +void KRDrawSlockChipChrome(OH_Drawing_Canvas *canvas, OH_Drawing_Typography *typography, + const std::vector &runs, float drawOffsetY, bool drawFill) { + if (!canvas || !typography || runs.empty()) { + return; + } + const float density = KRConfig::GetDpi(); + OH_Drawing_Brush *brush = OH_Drawing_BrushCreate(); + OH_Drawing_BrushSetAntiAlias(brush, drawFill); + OH_Drawing_CanvasAttachBrush(canvas, brush); + + for (const auto &run : runs) { + auto fragments = KRCollectSlockChromeFragments(typography, run); + if (fragments.empty()) { + continue; + } + OH_Drawing_BrushSetColor(brush, drawFill ? run.fill_color : 0xFF000000); + const float innerPadding = run.font_size_px * kSlockChipInnerPaddingRatio; + const float chipHeight = run.font_size_px * kSlockChipLineHeightRatio; + const float borderWidth = std::max(1.0f, density * kSlockChipBorderWidthVp); + for (size_t i = 0; i < fragments.size(); ++i) { + const auto &fragment = fragments[i]; + const bool isSpanStart = i == 0; + const bool isSpanEnd = i + 1 == fragments.size(); + const float left = fragment.left - (isSpanStart ? innerPadding : 0.0f); + const float right = fragment.right + (isSpanEnd ? innerPadding : 0.0f); + const float centerY = (fragment.top + fragment.bottom) / 2.0f - drawOffsetY; + const float top = centerY - chipHeight / 2.0f; + const float bottom = centerY + chipHeight / 2.0f; + if (drawFill) { + KRDrawBrushRect(canvas, left, top, right, bottom); + continue; + } + + const float borderLeft = std::floor(left); + const float borderTop = std::floor(top); + const float borderRight = std::ceil(right); + const float borderBottom = std::ceil(bottom); + KRDrawBrushRect(canvas, borderLeft, borderTop, borderRight, borderTop + borderWidth); + KRDrawBrushRect(canvas, borderLeft, borderBottom - borderWidth, borderRight, borderBottom); + // Internal line-wrap boundaries are not real span edges. Keep their + // fill continuous without drawing side borders that would cover glyphs. + if (isSpanStart) { + KRDrawBrushRect(canvas, borderLeft, borderTop, borderLeft + borderWidth, borderBottom); + } + if (isSpanEnd) { + KRDrawBrushRect(canvas, borderRight - borderWidth, borderTop, borderRight, borderBottom); + } + } + } + + OH_Drawing_CanvasDetachBrush(canvas); + OH_Drawing_BrushDestroy(brush); +} + +} // namespace + // UTF-8 to UTF-16 static std::u16string utf8_to_utf16(const std::string& utf8_string) { std::wstring_convert, char16_t> converter; @@ -229,6 +346,11 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { } } + // Native Slock chip fill is painted after final typography layout but before + // selection and glyphs. The shadow reserved real inline advance at each true + // edge; this pass only paints inside that collision volume. + KRDrawSlockChipChrome(drawingHandle, textTypo, richTextShadow->SlockChromeRuns(), drawOffsetY, true); + if (!selection_rects_.selection_rects.empty()) { double density = KRConfig::GetDpi(); OH_Drawing_Brush *backgroundBrush = OH_Drawing_BrushCreate(); @@ -265,6 +387,7 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { } } if(line_count > 0){ + KRDrawSlockChipChrome(drawingHandle, textTypo, richTextShadow->SlockChromeRuns(), drawOffsetY, false); return; } } @@ -344,6 +467,9 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { OH_Drawing_TypographyDestroyTextBox(placeholder_rects); } } + + // Border is deliberately last so the 1dp edge stays crisp above glyph AA. + KRDrawSlockChipChrome(drawingHandle, textTypo, richTextShadow->SlockChromeRuns(), drawOffsetY, false); } void KRRichTextView::ToSetProp(const std::string &prop_key, const KRAnyValue &prop_value, @@ -764,6 +890,9 @@ KRParagraphInfo KRRichTextView::GetParagraphInfo() { } std::string KRRichTextView::GetSelectedContent(std::string &pre, std::string &post) { + if (auto richTextShadow = std::dynamic_pointer_cast(shadow_)) { + return richTextShadow->SemanticSelection(selection_rects_.start, selection_rects_.end, pre, post); + } std::u16string str16 = utf8_to_utf16(selection_rects_.text_content); if (selection_rects_.start > 0) { From 9bb84eb12ff255f00818b91c20296da3aa4c16ad Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 13:50:09 +0800 Subject: [PATCH 087/187] revert(android): remove pull refresh hard clamp (#15) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../compose/gestures/KuiklyScrollInfo.kt | 15 ----- .../kuikly/compose/material3/PullToRefresh.kt | 5 +- .../expand/component/list/KRRecyclerView.kt | 62 +------------------ .../component/list/OverScrollHandler.kt | 21 +------ .../list/PullToRefreshOverscrollClampTest.kt | 43 ------------- .../tencent/kuikly/core/views/ScrollerView.kt | 8 +-- 6 files changed, 6 insertions(+), 148 deletions(-) delete mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 434c304a5..837d857be 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -132,22 +132,10 @@ class KuiklyScrollInfo { */ var hasPullToRefresh: Boolean = false set(value) { - if (field == value) return field = value scrollView?.let { updatePullToRefreshOnScrollView(it, value) } } - /** Maximum visible top overscroll for pull-to-refresh, in logical pixels. */ - var pullToRefreshMaxDistance: Float = 0f - set(value) { - val normalized = value.coerceAtLeast(0f) - if (field == normalized) return - field = normalized - if (hasPullToRefresh) { - scrollView?.let { updatePullToRefreshOnScrollView(it, true) } - } - } - private fun updatePullToRefreshOnScrollView( targetScrollView: ScrollerView, enabled: Boolean @@ -156,9 +144,6 @@ class KuiklyScrollInfo { fun applyIfCurrent() { if (scrollView === targetScrollView && hasPullToRefresh == enabled) { targetScrollView.setHasPullToRefresh(enabled) - targetScrollView.setPullToRefreshMaxDistance( - if (enabled) pullToRefreshMaxDistance else 0f - ) } } if (KuiklyContextScheduler.isOnKuiklyThread(pagerId)) { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt index 0f5b63615..3eb190d1f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt @@ -192,8 +192,7 @@ fun LazyListScope.pullToRefreshItem( DefaultRefreshIndicator(progress, refreshing, threshold) } ) { - // Mark the list and publish its live Android overscroll limit before the first gesture. - scrollState.kuiklyInfo.pullToRefreshMaxDistance = refreshThreshold.value + // Mark that the current list uses PullToRefresh scrollState.kuiklyInfo.hasPullToRefresh = true item(key = "pull_to_refresh") { @@ -419,4 +418,4 @@ private fun DefaultRefreshIndicator( modifier = Modifier.padding(16.dp) ) } -} +} \ No newline at end of file diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt index a2ceebc1a..be3801e86 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt @@ -40,7 +40,6 @@ import com.tencent.kuikly.core.render.android.css.ktx.frameWidth import com.tencent.kuikly.core.render.android.css.ktx.nativeGestureViewHashCodeSet import com.tencent.kuikly.core.render.android.css.ktx.touchConsumeByKuikly import com.tencent.kuikly.core.render.android.css.ktx.toDpF -import com.tencent.kuikly.core.render.android.css.ktx.toPxF import com.tencent.kuikly.core.render.android.css.ktx.toPxI import com.tencent.kuikly.core.render.android.export.IKuiklyRenderViewExport import com.tencent.kuikly.core.render.android.export.KuiklyRenderCallback @@ -121,9 +120,6 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private var bouncesEnable = true internal var limitHeaderBounces = false - private var hasPullToRefresh = false - private var pullToRefreshMaxTranslationPx = 0f - /** * List上一次的滚动状态 */ @@ -506,8 +502,7 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? { return when (method) { - METHOD_SET_HAS_PULL_TO_REFRESH -> setHasPullToRefresh(params) - METHOD_SET_PULL_TO_REFRESH_MAX_DISTANCE -> setPullToRefreshMaxDistance(params) + METHOD_SET_HAS_PULL_TO_REFRESH -> null METHOD_CONTENT_OFFSET -> setContentOffset(params) METHOD_CONTENT_INSET_WHEN_END_DRAG -> contentInsetWhenEndDrag(params) METHOD_CONTENT_INSET -> contentInset(params) @@ -1319,47 +1314,6 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi overScrollHandler?.bounceWithContentInset(KRRecyclerContentViewContentInset(kuiklyRenderContext, ci)) } - private fun setHasPullToRefresh(params: String?) { - val enabled = params == "1" - if (hasPullToRefresh == enabled) return - hasPullToRefresh = enabled - if (!enabled) { - pullToRefreshMaxTranslationPx = 0f - } - KuiklyRenderLog.d( - VIEW_NAME, - "$PULL_TO_REFRESH_CLAMP_MARKER enabled=$enabled maxPx=$pullToRefreshMaxTranslationPx" - ) - } - - private fun setPullToRefreshMaxDistance(params: String?) { - val logicalDistance = params?.toFloatOrNull()?.coerceAtLeast(0f) ?: return - val maxTranslationPx = kuiklyRenderContext.toPxF(logicalDistance) - if (pullToRefreshMaxTranslationPx == maxTranslationPx) return - pullToRefreshMaxTranslationPx = maxTranslationPx - overScrollHandler?.clampPullToRefreshTranslationIfNeeded() - KuiklyRenderLog.d( - VIEW_NAME, - "$PULL_TO_REFRESH_CLAMP_MARKER enabled=$hasPullToRefresh " + - "logical=$logicalDistance maxPx=$maxTranslationPx" - ) - } - - internal fun clampPullToRefreshTranslation(value: Float): Float = - clampPullToRefreshTranslation( - value = value, - enabled = hasPullToRefresh, - maxTranslation = pullToRefreshMaxTranslationPx - ) - - internal fun logPullToRefreshClamp(rawValue: Float, clampedValue: Float) { - KuiklyRenderLog.d( - VIEW_NAME, - "$PULL_TO_REFRESH_CLAMP_MARKER rawPx=$rawValue " + - "clampedPx=$clampedValue maxPx=$pullToRefreshMaxTranslationPx" - ) - } - /** * Clear transient native state for Compose DSL reuse (not the native reuse pool). */ @@ -1379,9 +1333,6 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi lastScrollParentX = 0 lastScrollParentY = 0 nestedScrollLastMoveTime = 0L - // Reset pull-to-refresh bounds before this native list is reused by another Compose node. - hasPullToRefresh = false - pullToRefreshMaxTranslationPx = 0f // Reset position offset compensation accumulatedPositionOffsetX = 0 accumulatedPositionOffsetY = 0 @@ -1547,8 +1498,6 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi private const val METHOD_ABORT_CONTENT_OFFSET_ANIMATE = "abortContentOffsetAnimate" // 停止滚动动画 private const val METHOD_PREPARE_FOR_COMPOSE_REUSE = "prepareForComposeReuse" // Compose DSL 复用前重置瞬态 private const val METHOD_SET_HAS_PULL_TO_REFRESH = "setHasPullToRefresh" - private const val METHOD_SET_PULL_TO_REFRESH_MAX_DISTANCE = "setPullToRefreshMaxDistance" - private const val PULL_TO_REFRESH_CLAMP_MARKER = "kuikly_ptr_overscroll_clamp_v1" private const val NESTED_SCROLL = "nestedScroll" @@ -2075,12 +2024,3 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi return super.canScrollVertically(direction) } } - -internal fun clampPullToRefreshTranslation( - value: Float, - enabled: Boolean, - maxTranslation: Float -): Float { - if (!enabled || maxTranslation <= 0f || value <= 0f) return value - return value.coerceAtMost(maxTranslation) -} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt index f9c07223a..1cfde0f54 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt @@ -84,7 +84,6 @@ internal class OverScrollHandler( */ private var accumulatedTranslationX: Float = 0f private var accumulatedTranslationY: Float = 0f - private var didLogPullToRefreshClamp = false private val maxFlingVelocity = ViewConfiguration.get(recyclerView.context).scaledMaximumFlingVelocity private var velocityTracker = VelocityTracker.obtain() @@ -126,7 +125,6 @@ internal class OverScrollHandler( overScrollY = 0f accumulatedTranslationX = 0f accumulatedTranslationY = 0f - didLogPullToRefreshClamp = false contentInsetWhenEndDrag = null } @@ -144,7 +142,6 @@ internal class OverScrollHandler( private fun processDownEvent(activeIndex: Int, event: MotionEvent): Boolean { downing = true - didLogPullToRefreshClamp = false updatePointerData(activeIndex, event) if (forceOverScroll) { dragging = true @@ -418,12 +415,7 @@ internal class OverScrollHandler( ) { accumulatedTranslationY = contentView.translationY } - val rawTranslation = accumulatedTranslationY + offset - accumulatedTranslationY = recyclerView.clampPullToRefreshTranslation(rawTranslation) - if (rawTranslation != accumulatedTranslationY && !didLogPullToRefreshClamp) { - recyclerView.logPullToRefreshClamp(rawTranslation, accumulatedTranslationY) - didLogPullToRefreshClamp = true - } + accumulatedTranslationY += offset contentView.translationY = accumulatedTranslationY.roundToInt().toFloat() } else { if (accumulatedTranslationX != contentView.translationX && @@ -436,15 +428,6 @@ internal class OverScrollHandler( } } - fun clampPullToRefreshTranslationIfNeeded() { - if (!isVertical) return - val clamped = recyclerView.clampPullToRefreshTranslation(accumulatedTranslationY) - if (clamped == accumulatedTranslationY) return - accumulatedTranslationY = clamped - contentView.translationY = clamped.roundToInt().toFloat() - fireOverScrollCallback(contentView.translationX, contentView.translationY) - } - private fun updatePointerData(activeIndex: Int, motionEvent: MotionEvent) { val pointerId = motionEvent.getPointerId(activeIndex) val currentOffset = getCurrentOffset(activeIndex, motionEvent) @@ -508,4 +491,4 @@ internal interface OverScrollEventCallback { overScrollStart: Boolean, isDragging: Boolean ) -} +} \ No newline at end of file diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt deleted file mode 100644 index 764c25a68..000000000 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/PullToRefreshOverscrollClampTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tencent.kuikly.core.render.android.expand.component.list - -import org.junit.Assert.assertEquals -import org.junit.Test - -class PullToRefreshOverscrollClampTest { - @Test - fun clampsPositiveTopOverscrollToRefreshThreshold() { - assertEquals( - 240f, - clampPullToRefreshTranslation(value = 440f, enabled = true, maxTranslation = 240f), - 0f - ) - } - - @Test - fun keepsTranslationWithinThreshold() { - assertEquals( - 180f, - clampPullToRefreshTranslation(value = 180f, enabled = true, maxTranslation = 240f), - 0f - ) - } - - @Test - fun leavesNonPullToRefreshAndBottomOverscrollUnchanged() { - assertEquals( - 440f, - clampPullToRefreshTranslation(value = 440f, enabled = false, maxTranslation = 240f), - 0f - ) - assertEquals( - -440f, - clampPullToRefreshTranslation(value = -440f, enabled = true, maxTranslation = 240f), - 0f - ) - assertEquals( - 440f, - clampPullToRefreshTranslation(value = 440f, enabled = true, maxTranslation = 0f), - 0f - ) - } -} diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt index 33077d524..b1161d096 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/ScrollerView.kt @@ -399,12 +399,6 @@ open class ScrollerView : renderView?.callMethod("setHasPullToRefresh", if (enabled) "1" else "0", null) } } - - fun setPullToRefreshMaxDistance(maxDistance: Float) { - performTaskWhenRenderViewDidLoad { - renderView?.callMethod("setPullToRefreshMaxDistance", maxDistance.coerceAtLeast(0f).toString(), null) - } - } } enum class KRNestedScrollMode(val value: String){ @@ -852,4 +846,4 @@ data class SetContentOffsetAnimation(private val durationMs: Int, val damping: F return SetContentOffsetAnimation(durationMs, damping, velocity); } } -} +} \ No newline at end of file From 4b9b09aae78fd6bb514f25d7c19af61e4b6cdf8d Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 15:27:10 +0800 Subject: [PATCH 088/187] fix(android): bound vertical overscroll resistance (#16) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../component/list/OverScrollHandler.kt | 82 +++++- .../list/OverScrollResistanceTest.kt | 241 ++++++++++++++++++ 2 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollResistanceTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt index 1cfde0f54..583adb176 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollHandler.kt @@ -26,6 +26,7 @@ import android.view.ViewConfiguration import android.view.animation.DecelerateInterpolator import com.tencent.kuikly.core.render.android.css.ktx.toPxF import kotlin.math.abs +import kotlin.math.min import kotlin.math.roundToInt /** @@ -394,8 +395,27 @@ internal class OverScrollHandler( /** * 处理overScroll的值,随着currentTranslation越来越大, newOffset会越来越小,起到一个阻尼的效果 */ - private fun getNewOffset(currentTranslation: Float, offset: Float): Float = - offset / (NEW_OFFSET_ADD_FACTOR + abs(currentTranslation) / recyclerView.kuiklyRenderContext.toPxF(NEW_OFFSET_SCALE_FACTOR)) + private fun getNewOffset(currentTranslation: Float, offset: Float): Float { + val resistanceScalePx = recyclerView.kuiklyRenderContext.toPxF(NEW_OFFSET_SCALE_FACTOR) + val maxVerticalTranslationPx = if ( + isVertical && + ((offset > 0f && isInStart()) || (offset < 0f && isInEnd())) + ) { + maxOf( + recyclerView.height * MAX_VERTICAL_OVER_SCROLL_VIEWPORT_FRACTION, + recyclerView.kuiklyRenderContext.toPxF(MIN_VERTICAL_OVER_SCROLL_DP) + ) + } else { + null + } + return calculateOverScrollDelta( + currentTranslation = currentTranslation, + translationOffset = offset, + resistanceScalePx = resistanceScalePx, + maxTranslationPx = maxVerticalTranslationPx, + addFactor = NEW_OFFSET_ADD_FACTOR + ) + } private fun getTranslation(): Float { return if (isVertical) { @@ -449,8 +469,8 @@ internal class OverScrollHandler( } internal fun setTranslationByNestScrollTouch(parentDy: Float) { - val newOffset = getNewOffset(getTranslation(), parentDy) - setTranslation(-newOffset) + val translationOffset = getNewOffset(getTranslation(), -parentDy) + setTranslation(translationOffset) if (!overScrolling) { dragging = true fireBeginOverScrollCallback() @@ -466,14 +486,64 @@ internal class OverScrollHandler( companion object { private const val BOUND_BACK_DURATION = 250L - private const val NEW_OFFSET_ADD_FACTOR = 2 + private const val NEW_OFFSET_ADD_FACTOR = 2f private const val NEW_OFFSET_SCALE_FACTOR = 500f + private const val MAX_VERTICAL_OVER_SCROLL_VIEWPORT_FRACTION = 1f / 3f + private const val MIN_VERTICAL_OVER_SCROLL_DP = 160f private const val DIRECTION_SCROLL_UP = -1 private const val DIRECTION_SCROLL_DOWN = 1 } } +/** + * Returns the rendered translation delta for one pointer move. + * + * [maxTranslationPx] is supplied only while moving farther past a vertical edge. The + * remaining-distance multiplier preserves the existing short-drag resistance while making a long + * held drag approach a finite boundary smoothly instead of accumulating an unbounded blank region. + */ +internal fun calculateOverScrollDelta( + currentTranslation: Float, + translationOffset: Float, + resistanceScalePx: Float, + maxTranslationPx: Float? = null, + addFactor: Float = 2f +): Float { + val resistance = addFactor + abs(currentTranslation) / resistanceScalePx + val dampedDelta = translationOffset / resistance + val maxTranslation = maxTranslationPx?.takeIf { it > 0f } ?: return dampedDelta + if (currentTranslation * translationOffset < 0f) { + val offsetToZero = -currentTranslation * resistance + val crossesZero = if (translationOffset < 0f) { + translationOffset < offsetToZero + } else { + translationOffset > offsetToZero + } + if (!crossesZero) return dampedDelta + + val remainingOffset = translationOffset - offsetToZero + return -currentTranslation + calculateOverScrollDelta( + currentTranslation = 0f, + translationOffset = remainingOffset, + resistanceScalePx = resistanceScalePx, + maxTranslationPx = maxTranslation, + addFactor = addFactor + ) + } + val remaining = (maxTranslation - abs(currentTranslation)).coerceAtLeast(0f) + if (remaining == 0f) return 0f + + val remainingRatio = (remaining / maxTranslation).coerceIn(0f, 1f) + return min(abs(dampedDelta) * remainingRatio, remaining) * translationOffset.signOrZero() +} + +private fun Float.signOrZero(): Float = when { + this > 0f -> 1f + this < 0f -> -1f + else -> 0f +} + internal interface OverScrollEventCallback { fun onBeginDragOverScroll( offsetX: Float, @@ -491,4 +561,4 @@ internal interface OverScrollEventCallback { overScrollStart: Boolean, isDragging: Boolean ) -} \ No newline at end of file +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollResistanceTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollResistanceTest.kt new file mode 100644 index 000000000..7391ab544 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/list/OverScrollResistanceTest.kt @@ -0,0 +1,241 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.expand.component.list + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +class OverScrollResistanceTest { + + @Test + fun shortEndDragKeepsExistingInitialResistance() { + val delta = calculateOverScrollDelta( + currentTranslation = 0f, + translationOffset = -20f, + resistanceScalePx = 1_500f, + maxTranslationPx = 800f + ) + + assertEquals(-10f, delta, 0.0001f) + } + + @Test + fun shortStartDragKeepsExistingInitialResistance() { + val delta = calculateOverScrollDelta( + currentTranslation = 0f, + translationOffset = 20f, + resistanceScalePx = 1_500f, + maxTranslationPx = 800f + ) + + assertEquals(10f, delta, 0.0001f) + } + + @Test + fun longEndDragApproachesFiniteBoundary() { + val maxTranslation = 800f + var translation = 0f + + repeat(10_000) { + translation += calculateOverScrollDelta( + currentTranslation = translation, + translationOffset = -10f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + } + + assertTrue("end overscroll must stay inside the finite boundary", translation >= -maxTranslation) + assertTrue("a long drag should approach the boundary smoothly", translation < -790f) + } + + @Test + fun longStartDragApproachesFiniteBoundary() { + val maxTranslation = 800f + var translation = 0f + + repeat(10_000) { + translation += calculateOverScrollDelta( + currentTranslation = translation, + translationOffset = 10f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + } + + assertTrue("start overscroll must stay inside the finite boundary", translation <= maxTranslation) + assertTrue("a long drag should approach the boundary smoothly", translation > 790f) + } + + @Test + fun startDragCanCrossRefreshThresholdBeforeApproachingBoundary() { + val refreshThreshold = 240f + val maxTranslation = 480f + var translation = 0f + + repeat(200) { + translation += calculateOverScrollDelta( + currentTranslation = translation, + translationOffset = 20f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + } + + assertTrue("pull-to-refresh must be able to cross its threshold", translation > refreshThreshold) + assertTrue("start overscroll must remain below the finite boundary", translation <= maxTranslation) + } + + @Test + fun singleLargeMoveCannotCrossFiniteBoundary() { + val maxTranslation = 800f + val delta = calculateOverScrollDelta( + currentTranslation = -790f, + translationOffset = -10_000f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(-10f, delta, 0.0001f) + } + + @Test + fun singleLargeStartMoveCannotCrossFiniteBoundary() { + val maxTranslation = 800f + val delta = calculateOverScrollDelta( + currentTranslation = 790f, + translationOffset = 10_000f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(10f, delta, 0.0001f) + } + + @Test + fun topTranslationCrossingZeroUsesBottomBoundaryForRemainder() { + val maxTranslation = 800f + val current = 100f + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = -10_000f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(-maxTranslation, current + delta, 0.0001f) + } + + @Test + fun bottomTranslationCrossingZeroUsesTopBoundaryForRemainder() { + val maxTranslation = 800f + val current = -100f + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = 10_000f, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(maxTranslation, current + delta, 0.0001f) + } + + @Test + fun smallReverseMoveBeforeZeroKeepsExistingResistance() { + val current = 100f + val translationOffset = -100f + val scale = 1_500f + val expected = translationOffset / (2f + abs(current) / scale) + + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = translationOffset, + resistanceScalePx = scale, + maxTranslationPx = 800f + ) + + assertEquals(expected, delta, 0.0001f) + assertTrue("a small reverse move must not jump across zero", current + delta > 0f) + } + + @Test + fun nestedParentDeltaCrossingZeroUsesSameBottomBoundary() { + val maxTranslation = 800f + val current = 100f + val parentDy = 10_000f + val translationOffset = -parentDy + + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = translationOffset, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(-maxTranslation, current + delta, 0.0001f) + } + + @Test + fun nestedParentDeltaCrossingZeroUsesSameTopBoundary() { + val maxTranslation = 800f + val current = -100f + val parentDy = -10_000f + val translationOffset = -parentDy + + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = translationOffset, + resistanceScalePx = 1_500f, + maxTranslationPx = maxTranslation + ) + + assertEquals(maxTranslation, current + delta, 0.0001f) + } + + @Test + fun unboundedPathsKeepExistingResistance() { + val current = 300f + val translationOffset = 120f + val scale = 1_500f + val expected = translationOffset / (2f + abs(current) / scale) + + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = translationOffset, + resistanceScalePx = scale + ) + + assertEquals(expected, delta, 0.0001f) + } + + @Test + fun movingBackFromEndKeepsExistingResistance() { + val current = -500f + val translationOffset = 100f + val scale = 1_500f + val expected = translationOffset / (2f + abs(current) / scale) + + val delta = calculateOverScrollDelta( + currentTranslation = current, + translationOffset = translationOffset, + resistanceScalePx = scale + ) + + assertEquals(expected, delta, 0.0001f) + } +} From 5aea44fa97fb7782b821c108522b270d32e881d1 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 19:02:59 +0800 Subject: [PATCH 089/187] fix(ohos): apply rich-text brush colors before attach (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set each Slock rich-text chrome run's brush color before attaching the brush to the OHOS drawing canvas, then detach after the run. This preserves the intended semantic fill colors instead of the native default black.\n\nSigned-off-by: KMP-专家 --- .../expand/components/richtext/KRRichTextView.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index 8c3894dc1..e6662460b 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -122,14 +122,17 @@ void KRDrawSlockChipChrome(OH_Drawing_Canvas *canvas, OH_Drawing_Typography *typ const float density = KRConfig::GetDpi(); OH_Drawing_Brush *brush = OH_Drawing_BrushCreate(); OH_Drawing_BrushSetAntiAlias(brush, drawFill); - OH_Drawing_CanvasAttachBrush(canvas, brush); for (const auto &run : runs) { auto fragments = KRCollectSlockChromeFragments(typography, run); if (fragments.empty()) { continue; } + // Native Drawing captures the brush state when it is attached to the + // canvas. Set the per-run color first; mutating an already attached + // brush leaves some HarmonyOS versions drawing the default black. OH_Drawing_BrushSetColor(brush, drawFill ? run.fill_color : 0xFF000000); + OH_Drawing_CanvasAttachBrush(canvas, brush); const float innerPadding = run.font_size_px * kSlockChipInnerPaddingRatio; const float chipHeight = run.font_size_px * kSlockChipLineHeightRatio; const float borderWidth = std::max(1.0f, density * kSlockChipBorderWidthVp); @@ -162,9 +165,9 @@ void KRDrawSlockChipChrome(OH_Drawing_Canvas *canvas, OH_Drawing_Typography *typ KRDrawBrushRect(canvas, borderRight - borderWidth, borderTop, borderRight, borderBottom); } } + OH_Drawing_CanvasDetachBrush(canvas); } - OH_Drawing_CanvasDetachBrush(canvas); OH_Drawing_BrushDestroy(brush); } From 5909030f88554a6d5713bb697a3850dac5b1eede Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 10 Jul 2026 23:40:45 +0800 Subject: [PATCH 090/187] fix(compose): coalesce semantics updates per frame to prevent fling white screen (#1523) (#18) After #1391 removed the accessibility gate, every semantics invalidation triggered a full tree walk during fling, blocking the context thread and causing white frames. Defer flushing to draw() so updates happen at most once per frame while preserving testTag behavior when accessibility is off. (cherry picked from commit e0ef15b35659c765e33b918549d5cb659291c434) Co-authored-by: luoyibu Co-authored-by: Cursor --- .../tencent/kuikly/compose/ui/node/RootNodeOwner.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt index d6e1909cf..9f3a43b8a 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/RootNodeOwner.kt @@ -162,6 +162,7 @@ internal class RootNodeOwner( } private var needClearObservations = false + private var semanticsChangePending = false private fun clearInvalidObservations() { if (needClearObservations) { @@ -207,6 +208,10 @@ internal class RootNodeOwner( // graphicsLayer = null // the root node will provide the root graphics layer ) clearInvalidObservations() + if (semanticsChangePending) { + semanticsChangePending = false + semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) + } } fun setRootModifier(modifier: Modifier) { @@ -400,7 +405,10 @@ internal class RootNodeOwner( override fun onSemanticsChange() { // platformContext.semanticsOwnerListener?.onSemanticsChange(semanticsOwner) - semanticsKuiklyHandler.onSemanticsChange(semanticsOwner) + // Coalesce to at most once per frame: this fires per semantics invalidation + // (dozens of times during a single fling remeasure), and each handler pass + // walks the whole merged semantics tree. + semanticsChangePending = true } override fun onZIndexChange(layoutNode: LayoutNode) { From 9aae59fc5ee4afbbd2d8b928520357c56d4c06ae Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sat, 11 Jul 2026 03:34:09 +0800 Subject: [PATCH 091/187] fix(input): reject stale controlled callbacks by revision (#19) Add monotonic controlled text input revisions so Android and aligned iOS render events retain the native state revision captured when their payload is constructed. Compose rejects only callbacks older than the latest issued revision, while legacy payloads remain compatible. OHOS intentionally remains legacy/compile-only until its asynchronous ArkUI events can be associated with individual writes.\n\nSigned-off-by: Input-Experience-Engineer --- compose/build.2.1.21.gradle.kts | 4 +- .../compose/foundation/text/CoreTextField.kt | 38 ++++++++-- .../text/TextInputSyncRevisionTrackerTest.kt | 70 +++++++++++++++++++ .../expand/component/KRTextFieldView.kt | 37 +++++++--- .../TextInputSyncRevisionStateTest.kt | 38 ++++++++++ .../Extension/Components/KRTextAreaView.m | 18 +++-- .../Extension/Components/KRTextFieldView.m | 11 ++- .../tencent/kuikly/core/views/InputView.kt | 6 +- .../tencent/kuikly/core/views/TextAreaView.kt | 3 +- .../kuikly/core/views/TextInputState.kt | 9 ++- 10 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputSyncRevisionTrackerTest.kt create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextInputSyncRevisionStateTest.kt diff --git a/compose/build.2.1.21.gradle.kts b/compose/build.2.1.21.gradle.kts index 21e00c118..5da92750a 100644 --- a/compose/build.2.1.21.gradle.kts +++ b/compose/build.2.1.21.gradle.kts @@ -80,7 +80,7 @@ kotlin { } commonTest.dependencies { -// implementation(libs.kotlin.test) + implementation(kotlin("test")) } // Android 特有源集中添加 ProfileInstaller 依赖 @@ -132,4 +132,4 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } -} \ No newline at end of file +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt index dba429929..9f521f1d9 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt @@ -203,6 +203,7 @@ internal fun CoreTextField( var pendingTextInputStateText by remember { mutableStateOf(null) } // 记录上一次原生层真实生效的编辑态,避免仅因 text 相同而误判 selection/composition 同步 var lastSyncedTextInputState by remember { mutableStateOf(null) } + val textInputSyncRevisionTracker = remember { TextInputSyncRevisionTracker() } // 标记是否正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 var isProcessingNativeEvent by remember { mutableStateOf(false) } @@ -445,6 +446,9 @@ internal fun CoreTextField( set(Triple(onValueChange, onLimitChange, maxLength)) { withTextAreaView { getViewEvent().textInputStateChange { + if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { + return@textInputStateChange + } // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 isProcessingNativeEvent = true pendingTextInputStateText = it.text @@ -454,7 +458,8 @@ internal fun CoreTextField( selectionEnd = it.selectionEnd, compositionStart = it.compositionStart, compositionEnd = it.compositionEnd, - length = it.length + length = it.length, + syncRevision = it.syncRevision ) autoHeightTextAreaView.getViewAttr() .updatePropCache(TextConst.VALUE, it.text) @@ -476,6 +481,9 @@ internal fun CoreTextField( dispatchLimitChange(it.length, pendingLimitChangeNotification) } getViewEvent().selectionChange { + if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { + return@selectionChange + } // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 isProcessingNativeEvent = true lastSyncedTextInputState = TextInputState( @@ -484,7 +492,8 @@ internal fun CoreTextField( selectionEnd = it.selectionEnd, compositionStart = it.compositionStart, compositionEnd = it.compositionEnd, - length = it.length + length = it.length, + syncRevision = it.syncRevision ) val composition = if ( it.compositionStart != TextInputState.NO_COMPOSITION && @@ -503,6 +512,9 @@ internal fun CoreTextField( ) } getViewEvent().textDidChange { + if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { + return@textDidChange + } val shouldIgnoreFallback = pendingTextInputStateText == it.text pendingTextInputStateText = null if (shouldIgnoreFallback) { @@ -584,8 +596,11 @@ internal fun CoreTextField( !(lastSyncedTextInputState?.hasSameEditingState(incomingTextInputState) ?: false) if (shouldSyncToNative) { - setTextInputState(incomingTextInputState) - lastSyncedTextInputState = incomingTextInputState + val revisionedState = incomingTextInputState.copy( + syncRevision = textInputSyncRevisionTracker.issue() + ) + setTextInputState(revisionedState) + lastSyncedTextInputState = revisionedState } // 长度计算统一依赖原生层回调,避免 Kotlin 层和原生层计算不一致 @@ -600,6 +615,21 @@ internal fun CoreTextField( } } } + +internal class TextInputSyncRevisionTracker { + private var latestIssuedRevision: Int = 0 + + fun issue(): Int { + latestIssuedRevision += 1 + return latestIssuedRevision + } + + fun isStale(callbackRevision: Int?): Boolean = + callbackRevision != null && + latestIssuedRevision != 0 && + callbackRevision < latestIssuedRevision +} + /** * 将 Modifier 拆分为两部分:SetPropElement/SetEventElement 和其他 Element * 使用 foldOut 从内到外遍历,保持原始顺序 diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputSyncRevisionTrackerTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputSyncRevisionTrackerTest.kt new file mode 100644 index 000000000..522329368 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputSyncRevisionTrackerTest.kt @@ -0,0 +1,70 @@ +package com.tencent.kuikly.compose.foundation.text + +import com.tencent.kuikly.core.nvi.serialization.json.JSONObject +import com.tencent.kuikly.core.views.TextInputState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TextInputSyncRevisionTrackerTest { + @Test + fun delayedCallbackFromBeforeHostClearRemainsStaleAcrossSchedulingTurns() { + val tracker = TextInputSyncRevisionTracker() + val editRevision = tracker.issue() + assertFalse(tracker.isStale(editRevision)) + + val clearRevision = tracker.issue() + repeat(3) { + assertTrue(tracker.isStale(editRevision)) + } + assertFalse(tracker.isStale(clearRevision)) + } + + @Test + fun currentRevisionAcceptsAckDifferentImeCommitAndSameTextReentry() { + val tracker = TextInputSyncRevisionTracker() + tracker.issue() + val clearRevision = tracker.issue() + + assertFalse(tracker.isStale(clearRevision)) // programmatic clear ack + assertFalse(tracker.isStale(clearRevision)) // an IME composition update + assertFalse(tracker.isStale(clearRevision)) // an IME commit with different text + assertFalse(tracker.isStale(clearRevision)) // the submitted text pasted again + } + + @Test + fun missingRevisionRemainsBackwardCompatible() { + val tracker = TextInputSyncRevisionTracker() + tracker.issue() + val legacyPayload = TextInputState.decode(JSONObject("""{"text":"legacy edit"}""")) + + assertEquals(null, legacyPayload.syncRevision) + assertFalse(tracker.isStale(legacyPayload.syncRevision)) + } + + @Test + fun zeroRevisionIsStaleAfterControlledStateHasBeenIssued() { + val tracker = TextInputSyncRevisionTracker() + tracker.issue() + + assertTrue(tracker.isStale(0)) + } + + @Test + fun newerRevisionIsNotDiscardedAsStale() { + val tracker = TextInputSyncRevisionTracker() + tracker.issue() + + assertFalse(tracker.isStale(2)) + } + + @Test + fun textInputStateCarriesRevisionAcrossRenderProtocol() { + val encoded = TextInputState(text = "sent body", syncRevision = 7).encode() + + val decoded = TextInputState.decode(JSONObject(encoded)) + + assertEquals(7, decoded.syncRevision) + } +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt index d7ef054cd..fd7b814d1 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt @@ -88,6 +88,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : private var selectionChangeCallback: KuiklyRenderCallback? = null private var isSettingTextInputState = false + private val textInputSyncRevisionState = TextInputSyncRevisionState() /** * 聚焦回调 @@ -672,6 +673,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : private fun setTextInputState(params: String?) { val json = runCatching { JSONObject(params ?: "{}") }.getOrElse { JSONObject() } + textInputSyncRevisionState.apply(json.optIntOrNull(KEY_SYNC_REVISION)) val rawText = json.optString(KEY_TEXT, "") if (shouldRejectProgrammaticShortcodeInput(rawText)) { textLengthBeyondLimitCallback?.invoke(null) @@ -851,16 +853,9 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : } else { null } - return if (length == null) { - mapOf( - KEY_TEXT to rawText.toString() - ) - } else { - mapOf( - KEY_TEXT to rawText.toString(), - KEY_LENGTH to length!! - ) - } + val result = mutableMapOf(KEY_TEXT to rawText.toString()) + length?.let { result[KEY_LENGTH] = it } + return textInputSyncRevisionState.snapshot(result) } private fun createTextInputStateParamMap(): Map { @@ -882,7 +877,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : null } length?.let { result[KEY_LENGTH] = it } - return result + return textInputSyncRevisionState.snapshot(result) } private fun resetDefaultStyle() { @@ -1100,6 +1095,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : private const val KEY_COMPOSITION_START = "compositionStart" private const val KEY_COMPOSITION_END = "compositionEnd" private const val KEY_LENGTH = "length" + private const val KEY_SYNC_REVISION = "syncRevision" private const val NO_COMPOSITION = -1 private const val LENGTH_LIMIT_TYPE_UNSET = -1 @@ -1109,6 +1105,25 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : } } +internal class TextInputSyncRevisionState { + var current: Int = 0 + private set + + fun apply(requestedRevision: Int?) { + if (requestedRevision != null) { + current = requestedRevision + } + } + + fun snapshot(payload: MutableMap): Map { + payload["syncRevision"] = current + return payload + } +} + +private fun JSONObject.optIntOrNull(key: String): Int? = + if (has(key)) optInt(key) else null + private val PROGRAMMATIC_SHORTCODE_REGEX = Regex("\\[[a-zA-Z0-9_\\-]+\\]") internal fun shouldRejectProgrammaticShortcodeInputRequest( diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextInputSyncRevisionStateTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextInputSyncRevisionStateTest.kt new file mode 100644 index 000000000..95a139c41 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextInputSyncRevisionStateTest.kt @@ -0,0 +1,38 @@ +package com.tencent.kuikly.core.render.android.expand.component + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TextInputSyncRevisionStateTest { + @Test + fun eventPayloadKeepsRevisionCapturedBeforeNewControlledWrite() { + val state = TextInputSyncRevisionState() + state.apply(1) + val delayedEventPayload = state.snapshot(mutableMapOf("text" to "sent body")) + + state.apply(2) + + assertEquals(1, delayedEventPayload["syncRevision"]) + } + + @Test + fun currentUserEditCarriesLatestControlledRevision() { + val state = TextInputSyncRevisionState() + state.apply(1) + state.apply(2) + + val currentEventPayload = state.snapshot(mutableMapOf("text" to "new draft")) + + assertEquals(2, currentEventPayload["syncRevision"]) + } + + @Test + fun legacyControlledWriteDoesNotResetRevision() { + val state = TextInputSyncRevisionState() + state.apply(2) + + state.apply(null) + + assertEquals(2, state.current) + } +} diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index e81f2665a..e2da95ed7 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -113,6 +113,7 @@ - (void)p_updateFont; @implementation KRTextAreaView { NSString *_text; BOOL _didAddKeyboardNotification; + NSInteger _textInputSyncRevision; NSMutableDictionary *_props; BOOL _ignoreTextDidChanged; /** 显式设置的光标颜色 */ @@ -393,6 +394,9 @@ - (void)css_setTextInputState:(NSDictionary *)args { NSError *error = nil; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (!json) return; + if (json[@"syncRevision"] != nil) { + _textInputSyncRevision = [json[@"syncRevision"] integerValue]; + } NSString *requestedRawText = json[@"text"] ?: @""; NSInteger requestedSelectionStart = json[@"selectionStart"] ? [json[@"selectionStart"] integerValue] : requestedRawText.length; @@ -410,6 +414,7 @@ - (void)css_setTextInputState:(NSDictionary *)args { @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:outputText]) }); } @@ -469,6 +474,7 @@ - (void)css_setTextInputState:(NSDictionary *)args { @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:outputText]) }); } @@ -485,6 +491,7 @@ - (void)css_getTextInputState:(NSDictionary *)args { @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:rawText]) }); } @@ -764,7 +771,7 @@ - (void)textViewDidChange:(UITextView *)textView { // 文本值变化 if (enablePinyinCallback) { if (self.css_textDidChange) { NSString *text = [self p_outputText].copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); + self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text]), @"syncRevision": @(_textInputSyncRevision)}); } } return; @@ -775,7 +782,7 @@ - (void)textViewDidChange:(UITextView *)textView { // 文本值变化 if (self.css_textDidChange) { NSString *text = [self p_outputText].copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); + self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text]), @"syncRevision": @(_textInputSyncRevision)}); } if (self.css_textInputStateChange) { @@ -787,6 +794,7 @@ - (void)textViewDidChange:(UITextView *)textView { // 文本值变化 @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:rawText]) }); } @@ -806,7 +814,8 @@ - (void)textViewDidChangeSelection:(UITextView *)textView { @"selectionStart": @(outputSelectionRange.location), @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), - @"compositionEnd": @(-1) + @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision) }); } @@ -889,7 +898,7 @@ - (void)paste:(id)sender { self.css_textLengthBeyondLimit(@{}); } if (self.css_textDidChange) { - self.css_textDidChange(@{@"text": newRawText, @"length": @([self p_calculateLengthForText:newRawText])}); + self.css_textDidChange(@{@"text": newRawText, @"length": @([self p_calculateLengthForText:newRawText]), @"syncRevision": @(_textInputSyncRevision)}); } if (self.css_textInputStateChange) { NSRange outputSelectionRange = [self p_getOutputSelectionRange]; @@ -899,6 +908,7 @@ - (void)paste:(id)sender { @"selectionEnd": @(NSMaxRange(outputSelectionRange)), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:newRawText]) }); } diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index ec082b767..f7e5442d8 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -101,6 +101,8 @@ @implementation KRTextFieldView { BOOL _ignoreSelectionChange; /** suppress intermediate textInputStateChange during programmatic state sync */ BOOL _suppressTextInputStateChange; + /** revision of the most recently applied controlled textInputState */ + NSInteger _textInputSyncRevision; /** collect props */ NSMutableDictionary *_props; /** 显式设置的光标颜色 */ @@ -338,6 +340,9 @@ - (void)css_setTextInputState:(NSDictionary *)args { NSError *error = nil; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (!json) return; + if (json[@"syncRevision"] != nil) { + _textInputSyncRevision = [json[@"syncRevision"] integerValue]; + } NSString *requestedRawText = json[@"text"] ?: @""; NSInteger requestedSelectionStart = json[@"selectionStart"] ? [json[@"selectionStart"] integerValue] : requestedRawText.length; @@ -393,6 +398,7 @@ - (void)css_getTextInputState:(NSDictionary *)args { @"selectionEnd": @(cursorEnd), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:self.text]) }); } @@ -479,7 +485,7 @@ - (void)onTextFeildTextChanged:(UITextField *)textField { // 文本值变化 if (enablePinyinCallback) { if (self.css_textDidChange) { NSString *text = textField.text.copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); + self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text]), @"syncRevision": @(_textInputSyncRevision)}); } } return; @@ -487,7 +493,7 @@ - (void)onTextFeildTextChanged:(UITextField *)textField { // 文本值变化 [self p_limitTextInput]; if (self.css_textDidChange) { NSString *text = textField.text.copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); + self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text]), @"syncRevision": @(_textInputSyncRevision)}); } [self p_notifyTextInputStateChangeIfNeeded]; } @@ -638,6 +644,7 @@ - (NSDictionary *)p_currentTextInputStatePayload { @"selectionEnd": @(selectionEnd), @"compositionStart": @(-1), @"compositionEnd": @(-1), + @"syncRevision": @(_textInputSyncRevision), @"length": @([self p_calculateLengthForText:text]) }; } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt index 088de9a04..033866239 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt @@ -394,7 +394,8 @@ class InputAttr : Attr() { data class InputParams( val text: String, val imeAction: String? = null, - val length: Int? = null + val length: Int? = null, + val syncRevision: Int? = null ) data class KeyboardParams( @@ -419,7 +420,8 @@ class InputEvent : Event() { it as JSONObject val text = it.optString("text") val length = if (it.has("length")) it.optInt("length") else null - handler(InputParams(text, length = length)) + val syncRevision = if (it.has("syncRevision")) it.optInt("syncRevision") else null + handler(InputParams(text, length = length, syncRevision = syncRevision)) }, isSync = isSyncEdit) } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt index 123735b48..834fb9c51 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt @@ -654,7 +654,8 @@ open class TextAreaEvent : Event() { it as JSONObject val text = it.optString("text") val length = if (it.has("length")) it.optInt("length") else null - val params = InputParams(text, length = length) + val syncRevision = if (it.has("syncRevision")) it.optInt("syncRevision") else null + val params = InputParams(text, length = length, syncRevision = syncRevision) syncTextDidChangeObservers.forEach { observer -> observer.invoke(params) } textDidChangeHandler?.invoke(params) }, isSync = isSyncEdit || syncTextDidChangeObservers.isNotEmpty()) diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextInputState.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextInputState.kt index 018d942d5..ef11ab0f5 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextInputState.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextInputState.kt @@ -29,7 +29,8 @@ data class TextInputState( val selectionEnd: Int = selectionStart, val compositionStart: Int = NO_COMPOSITION, val compositionEnd: Int = NO_COMPOSITION, - val length: Int? = null + val length: Int? = null, + val syncRevision: Int? = null ) { fun toJSONObject(): JSONObject { return JSONObject().apply { @@ -39,6 +40,7 @@ data class TextInputState( put(KEY_COMPOSITION_START, compositionStart) put(KEY_COMPOSITION_END, compositionEnd) length?.let { put(KEY_LENGTH, it) } + syncRevision?.let { put(KEY_SYNC_REVISION, it) } } } @@ -61,6 +63,7 @@ data class TextInputState( const val KEY_COMPOSITION_START = "compositionStart" const val KEY_COMPOSITION_END = "compositionEnd" const val KEY_LENGTH = "length" + const val KEY_SYNC_REVISION = "syncRevision" fun decode(params: JSONObject?): TextInputState { val json = params ?: JSONObject() @@ -86,13 +89,15 @@ data class TextInputState( NO_COMPOSITION } val length = if (json.has(KEY_LENGTH)) json.optInt(KEY_LENGTH) else null + val syncRevision = if (json.has(KEY_SYNC_REVISION)) json.optInt(KEY_SYNC_REVISION) else null return TextInputState( text = text, selectionStart = selectionStart, selectionEnd = selectionEnd, compositionStart = compositionStart, compositionEnd = compositionEnd, - length = length + length = length, + syncRevision = syncRevision ) } } From af2c4d514de76ae6c2add2a37a891b8c0e068262 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sat, 11 Jul 2026 16:45:01 +0800 Subject: [PATCH 092/187] fix(render): marshal off-context native calls (#20) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../context/IKuiklyRenderContextHandler.kt | 35 +++++ .../context/KuiklyRenderJvmContextHandler.kt | 29 +++-- .../render/android/core/KuiklyRenderCore.kt | 22 +--- .../context/NativeCallContextDispatchTest.kt | 123 ++++++++++++++++++ core-render-ios/Core/KuiklyRenderCore.m | 14 +- .../KuiklyRenderFrameworkContextHandler.m | 33 ++++- .../Protocol/KuiklyRenderContextProtocol.h | 17 +++ .../context/IKRRenderNativeContextHandler.h | 17 +++ .../KRRenderNativeContextHandlerManager.cpp | 37 ++++-- .../KRRenderNativeContextHandlerManager.h | 5 + .../cpp/libohos_render/core/KRRenderCore.cpp | 15 +-- 11 files changed, 278 insertions(+), 69 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/context/NativeCallContextDispatchTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/IKuiklyRenderContextHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/IKuiklyRenderContextHandler.kt index c7ab1d376..9f07f6254 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/IKuiklyRenderContextHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/IKuiklyRenderContextHandler.kt @@ -106,5 +106,40 @@ enum class KuiklyRenderNativeMethod(val value: Int) { typealias KuiklyRenderNativeMethodCallback = (methodId: KuiklyRenderNativeMethod, args: List) -> Any? +internal fun kuiklyNativeMethodRequiresContextThread( + method: KuiklyRenderNativeMethod, + args: List +): Boolean { + if (method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallModuleMethod || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallTDFNativeMethod + ) { + return (args.getOrNull(5) as? Int ?: 0) == 1 + } + return method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCalculateRenderViewSize || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCreateShadow || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodRemoveShadow || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetShadowForView || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetShadowProp || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetTimeout || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallShadowMethod || + method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSyncFlushUI +} + +internal fun dispatchKuiklyNativeCall( + isContextThread: Boolean, + requiresContextThread: Boolean, + scheduleOnContextThread: (() -> Unit) -> Unit, + call: () -> Any? +): Any? { + if (isContextThread) { + return call() + } + check(!requiresContextThread) { + "Synchronous Kuikly native calls must run on the context thread" + } + scheduleOnContextThread { call() } + return null +} + // 用于记录各个callNative的task的次数 internal var nativeMethodCallCounts = IntArray(KuiklyRenderNativeMethod.values().size + 1) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/KuiklyRenderJvmContextHandler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/KuiklyRenderJvmContextHandler.kt index d810e15cc..33b9527b2 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/KuiklyRenderJvmContextHandler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/context/KuiklyRenderJvmContextHandler.kt @@ -17,8 +17,10 @@ package com.tencent.kuikly.core.render.android.context import com.tencent.kuikly.core.IKuiklyCoreEntry import com.tencent.kuikly.core.manager.BridgeManager +import com.tencent.kuikly.core.nvi.NativeBridge import com.tencent.kuikly.core.render.android.css.ktx.isMainThread import com.tencent.kuikly.core.render.android.exception.ErrorReason +import com.tencent.kuikly.core.render.android.scheduler.KuiklyRenderCoreContextScheduler /** * 渲染流程在JVM环境执行的处理器 @@ -74,17 +76,24 @@ class KuiklyRenderJvmContextHandler : KuiklyRenderCommonContextHandler(), IKuikl arg5: Any? ): Any? { assert(!isMainThread()) + val method = KuiklyRenderNativeMethod.fromInt(methodId) + val args = listOf(arg0, arg1, arg2, arg3, arg4, arg5) + return dispatchKuiklyNativeCall( + isContextThread = NativeBridge.isContextThread, + requiresContextThread = kuiklyNativeMethodRequiresContextThread(method, args), + scheduleOnContextThread = { task -> + KuiklyRenderCoreContextScheduler.scheduleTask(0) { task() } + }, + call = { invokeNativeCallback(method, args) } + ) + } + + private fun invokeNativeCallback( + method: KuiklyRenderNativeMethod, + args: List + ): Any? { try { - val result = callNativeCallback?.invoke( - KuiklyRenderNativeMethod.fromInt(methodId), listOf( - arg0, - arg1, - arg2, - arg3, - arg4, - arg5 - ) - ) + val result = callNativeCallback?.invoke(method, args) return result?.toKotlinObject() } catch (t: Throwable) { // 这里catch的异常类型是故意设置成Throwable的,因为callKotlinMethod运行的是KTV业务代码 diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/core/KuiklyRenderCore.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/core/KuiklyRenderCore.kt index e7f8cc0f7..44919ad4a 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/core/KuiklyRenderCore.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/core/KuiklyRenderCore.kt @@ -29,6 +29,7 @@ import com.tencent.kuikly.core.render.android.context.KuiklyRenderNativeMethodCa import com.tencent.kuikly.core.render.android.context.IKuiklyRenderContextHandler import com.tencent.kuikly.core.render.android.context.KuiklyRenderNativeMethod import com.tencent.kuikly.core.render.android.context.KuiklyRenderJvmContextHandler +import com.tencent.kuikly.core.render.android.context.kuiklyNativeMethodRequiresContextThread import com.tencent.kuikly.core.render.android.context.nativeMethodCallCounts import com.tencent.kuikly.core.render.android.css.ktx.fifthArg import com.tencent.kuikly.core.render.android.css.ktx.fourthArg @@ -604,25 +605,7 @@ class KuiklyRenderCore( } private fun isSyncMethodCall(method: KuiklyRenderNativeMethod, args: List): Boolean { - if (method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallModuleMethod || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallTDFNativeMethod - ) { - val fifthArg = if (args.size >= IKuiklyRenderContextHandler.CALL_ARGS_COUNT) { - args[KRExtConst.SIXTH_ARG_INDEX] as? Int ?: KRExtConst.FIRST_ARG_INDEX - } else { - KRExtConst.FIRST_ARG_INDEX - } - return fifthArg == SYNC_CALL_TYPE - } - - return method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCalculateRenderViewSize || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCreateShadow || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodRemoveShadow || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetShadowForView || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetShadowProp || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetTimeout || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallShadowMethod || - method == KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSyncFlushUI + return kuiklyNativeMethodRequiresContextThread(method, args) } /** @@ -659,7 +642,6 @@ class KuiklyRenderCore( companion object { private var instanceIdProducer = 0L - private const val SYNC_CALL_TYPE = 1 private const val LAYOUT_VIEW_MAX_LOG_COUNT = 10 } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/context/NativeCallContextDispatchTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/context/NativeCallContextDispatchTest.kt new file mode 100644 index 000000000..4f6ee01ef --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/context/NativeCallContextDispatchTest.kt @@ -0,0 +1,123 @@ +package com.tencent.kuikly.core.render.android.context + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeCallContextDispatchTest { + + @Test + fun `off-context fire-and-forget calls preserve scheduler FIFO`() { + val scheduled = mutableListOf<() -> Unit>() + val calls = mutableListOf() + val results = mutableListOf() + + Thread { + repeat(3) { index -> + results += + dispatchKuiklyNativeCall( + isContextThread = false, + requiresContextThread = false, + scheduleOnContextThread = scheduled::add, + call = { calls += index } + ) + } + }.apply { + start() + join() + } + + assertTrue(calls.isEmpty()) + assertEquals(listOf(null, null, null), results) + scheduled.forEach { it() } + assertEquals(listOf(0, 1, 2), calls) + } + + @Test + fun `context-thread call stays inline and returns result`() { + var scheduled = false + + val result = dispatchKuiklyNativeCall( + isContextThread = true, + requiresContextThread = true, + scheduleOnContextThread = { scheduled = true }, + call = { "result" } + ) + + assertEquals("result", result) + assertFalse(scheduled) + } + + @Test + fun `off-context synchronous call fails without scheduling`() { + var scheduled = false + + assertThrows(IllegalStateException::class.java) { + dispatchKuiklyNativeCall( + isContextThread = false, + requiresContextThread = true, + scheduleOnContextThread = { scheduled = true }, + call = { "unreachable" } + ) + } + + assertFalse(scheduled) + } + + @Test + fun `native method classification matches renderer inline contract`() { + val asyncModuleArgs = listOf(null, null, null, null, null, 0) + val syncModuleArgs = listOf(null, null, null, null, null, 1) + + assertFalse( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallModuleMethod, + asyncModuleArgs + ) + ) + assertTrue( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallModuleMethod, + syncModuleArgs + ) + ) + assertFalse( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallViewMethod, + emptyList() + ) + ) + assertTrue( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodSetTimeout, + emptyList() + ) + ) + assertFalse( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallTDFNativeMethod, + asyncModuleArgs + ) + ) + assertTrue( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallTDFNativeMethod, + syncModuleArgs + ) + ) + assertFalse( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodFireFatalException, + emptyList() + ) + ) + assertFalse( + kuiklyNativeMethodRequiresContextThread( + KuiklyRenderNativeMethod.KuiklyRenderNativeMethodCallTDFNativeMethod, + emptyList() + ) + ) + } +} diff --git a/core-render-ios/Core/KuiklyRenderCore.m b/core-render-ios/Core/KuiklyRenderCore.m index 1084e8bc0..faf074935 100644 --- a/core-render-ios/Core/KuiklyRenderCore.m +++ b/core-render-ios/Core/KuiklyRenderCore.m @@ -271,19 +271,7 @@ - (void)p_registerNativeMethodWithMethod:(KuiklyRenderNativeMethod)method callba // 判断事件是否需要同步调用 - (BOOL)p_shouldSyncCallWithWithMethod:(KuiklyRenderNativeMethod)method args:(NSArray *)args { - if (method == KuiklyRenderNativeMethodCallModuleMethod) { - return [FIVE_ARG isKindOfClass:[NSNumber class]] ? [FIVE_ARG boolValue] : NO; // - } - return method == KuiklyRenderNativeMethodCalculateRenderViewSize || - method == KuiklyRenderNativeMethodCreateShadow || - method == KuiklyRenderNativeMethodRemoveShadow || - method == KuiklyRenderNativeMethodSetShadowForView || - method == KuiklyRenderNativeMethodSetShadowProp || - method == KuiklyRenderNativeMethodSetTimeout || - method == KuiklyRenderNativeMethodCallShadowMethod || - method == KuiklyRenderNativeMethodFireFatalException || - method == KuiklyRenderNativeMethodSyncFlushUI || - method == KuiklyRenderNativeMethodCallTDFModuleMethod; + return KRNativeMethodRequiresContextThread(method, args); } // 执行KuiklyKotlin侧调用Native侧的事件 diff --git a/core-render-ios/Handler/KuiklyRenderFrameworkContextHandler.m b/core-render-ios/Handler/KuiklyRenderFrameworkContextHandler.m index 8e1b7c5ae..b35b4364e 100644 --- a/core-render-ios/Handler/KuiklyRenderFrameworkContextHandler.m +++ b/core-render-ios/Handler/KuiklyRenderFrameworkContextHandler.m @@ -18,6 +18,7 @@ #import "KuiklyRenderThreadManager.h" #import "KRConvertUtil.h" #import +#import #import "KRLogModule.h" #define MAX_FRAMEWORK_NAME_LENGTH 100 @@ -124,12 +125,34 @@ - (void)registerCallNativeWtihCallback:(KuiklyRenderNativeMethodCallback)callbac #pragma mark - KRCallNativeDelegate - (id _Nullable)callNativeMethodId:(int32_t)methodId arg0:(id _Nullable)arg0 arg1:(id _Nullable)arg1 arg2:(id _Nullable)arg2 arg3:(id _Nullable)arg3 arg4:(id _Nullable)arg4 arg5:(id _Nullable)arg5 { + NSArray *args = @[KRSafeObject(arg1), + KRSafeObject(arg2), + KRSafeObject(arg3), + KRSafeObject(arg4), + KRSafeObject(arg5)]; + KuiklyRenderNativeMethod method = (KuiklyRenderNativeMethod)methodId; + if (![KuiklyRenderThreadManager isContextQueue]) { + // Fatal reporting already has a dedicated synchronous context-queue handoff in KuiklyRenderCore. + if (method == KuiklyRenderNativeMethodFireFatalException) { + id result = _nativeCallback ? _nativeCallback(method, args) : nil; + return [KRConvertUtil nativeObjectToKotlinObject:result]; + } + if (KRNativeMethodRequiresContextThread(method, args)) { + [KRLogModule logError:[NSString stringWithFormat: + @"synchronous native method %ld called off the context queue", (long)method]]; + abort(); + } + __weak typeof(self) weakSelf = self; + [KuiklyRenderThreadManager performOnContextQueueWithBlock:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf.nativeCallback) { + strongSelf.nativeCallback(method, args); + } + }]; + return nil; + } if (_nativeCallback) { - id result = _nativeCallback(methodId, @[KRSafeObject(arg1), - KRSafeObject(arg2), - KRSafeObject(arg3), - KRSafeObject(arg4), - KRSafeObject(arg5)]); + id result = _nativeCallback(method, args); return [KRConvertUtil nativeObjectToKotlinObject:result]; } return nil; diff --git a/core-render-ios/Protocol/KuiklyRenderContextProtocol.h b/core-render-ios/Protocol/KuiklyRenderContextProtocol.h index e2c425d06..716422b07 100644 --- a/core-render-ios/Protocol/KuiklyRenderContextProtocol.h +++ b/core-render-ios/Protocol/KuiklyRenderContextProtocol.h @@ -63,6 +63,23 @@ typedef NS_ENUM(NSUInteger, KuiklyRenderNativeMethod) { KuiklyRenderNativeMethodCallTDFModuleMethod = 17, /// "callTDFModuleMethod" 方法 }; +NS_INLINE BOOL KRNativeMethodRequiresContextThread(KuiklyRenderNativeMethod method, NSArray *args) { + if (method == KuiklyRenderNativeMethodCallModuleMethod) { + id syncCall = args.count > 4 ? args[4] : nil; + return [syncCall isKindOfClass:[NSNumber class]] ? [syncCall boolValue] : NO; + } + return method == KuiklyRenderNativeMethodCalculateRenderViewSize || + method == KuiklyRenderNativeMethodCreateShadow || + method == KuiklyRenderNativeMethodRemoveShadow || + method == KuiklyRenderNativeMethodSetShadowForView || + method == KuiklyRenderNativeMethodSetShadowProp || + method == KuiklyRenderNativeMethodSetTimeout || + method == KuiklyRenderNativeMethodCallShadowMethod || + method == KuiklyRenderNativeMethodFireFatalException || + method == KuiklyRenderNativeMethodSyncFlushUI || + method == KuiklyRenderNativeMethodCallTDFModuleMethod; +} + typedef id _Nullable (^KuiklyRenderNativeMethodCallback)(KuiklyRenderNativeMethod method, NSArray *args); diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h b/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h index 59209e9f3..688aca3ac 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h +++ b/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h @@ -54,6 +54,23 @@ enum class KuiklyRenderNativeMethod { KuiklyRenderNativeMethodCallTDFNativeMethod = 17 // "callTDFModuleMethod" }; +inline bool KRNativeMethodRequiresContextThread(const KuiklyRenderNativeMethod &method, + const std::shared_ptr &arg5) { + if (method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallModuleMethod) { + auto sync_call = arg5 ? arg5->toInt() : 0; + return sync_call == 1 || sync_call == 3; + } + return method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCalculateRenderViewSize || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCreateShadow || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodRemoveShadow || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetShadowProp || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetShadowForView || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetTimeout || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallShadowMethod || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSyncFlushUI || + method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallTDFNativeMethod; +} + class IKRRenderNativeContextHandler; class KRRenderContextParams; diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp index 335eb77e7..da6150c97 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp @@ -15,9 +15,11 @@ #include "libohos_render/context/KRRenderNativeContextHandlerManager.h" +#include #include "libohos_render/context/DefaultRenderNativeContextHandler.h" #include "libohos_render/manager/KRRenderManager.h" #include "libohos_render/scheduler/KRContextScheduler.h" +#include "libohos_render/utils/KRRenderLoger.h" extern CallKotlin callKotlin_; @@ -85,21 +87,28 @@ void KRRenderNativeContextHandlerManager::ScheduleDeallocRenderValues( KRRenderCValue KRRenderNativeContextHandlerManager::DispatchCallNative( const std::string &instanceId, int methodId, const KRRenderCValue &arg0, const KRRenderCValue &arg1, const KRRenderCValue &arg2, const KRRenderCValue &arg3, const KRRenderCValue &arg4, const KRRenderCValue &arg5) { - auto handler = context_handler_map_.Get(instanceId); - if (!handler || nullptr == KRRenderManager::GetInstance().GetRenderView(instanceId)) { - auto cv = KRRenderCValue(); - cv.type = KRRenderCValue::NULL_VALUE; - return cv; - } auto cv0 = KRRenderValue::Make(arg0); auto cv1 = KRRenderValue::Make(arg1); auto cv2 = KRRenderValue::Make(arg2); auto cv3 = KRRenderValue::Make(arg3); auto cv4 = KRRenderValue::Make(arg4); auto cv5 = KRRenderValue::Make(arg5); + auto method = static_cast(methodId); + if (!KRContextScheduler::IsCurrentOnContextThread()) { + if (KRNativeMethodRequiresContextThread(method, cv5)) { + KR_LOG_ERROR << "Synchronous Kuikly native method " << methodId + << " called off the context thread; aborting"; + std::abort(); + } + KRContextScheduler::ScheduleTask(0, [this, instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5]() mutable { + DispatchPreparedCallNative(instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5); + }); + KRRenderCValue null_return_value; + null_return_value.type = KRRenderCValue::NULL_VALUE; + return null_return_value; + } - auto return_value = - handler->OnCallNative(static_cast(methodId), cv0, cv1, cv2, cv3, cv4, cv5); + auto return_value = DispatchPreparedCallNative(instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5); if (return_value == nullptr) { KRRenderCValue null_return_value; null_return_value.type = KRRenderCValue::NULL_VALUE; @@ -108,3 +117,15 @@ KRRenderCValue KRRenderNativeContextHandlerManager::DispatchCallNative( ScheduleDeallocRenderValues(return_value); return return_value->toCValue(); } + +std::shared_ptr KRRenderNativeContextHandlerManager::DispatchPreparedCallNative( + const std::string &instanceId, const KuiklyRenderNativeMethod &method, std::shared_ptr &arg0, + std::shared_ptr &arg1, std::shared_ptr &arg2, + std::shared_ptr &arg3, std::shared_ptr &arg4, + std::shared_ptr &arg5) { + auto handler = context_handler_map_.Get(instanceId); + if (!handler || nullptr == KRRenderManager::GetInstance().GetRenderView(instanceId)) { + return nullptr; + } + return handler->OnCallNative(method, arg0, arg1, arg2, arg3, arg4, arg5); +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h index 7b83b852a..49ca36359 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h +++ b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h @@ -82,6 +82,11 @@ class KRRenderNativeContextHandlerManager { private: KRRenderNativeContextHandlerManager() {} + std::shared_ptr + DispatchPreparedCallNative(const std::string &instanceId, const KuiklyRenderNativeMethod &method, + std::shared_ptr &arg0, std::shared_ptr &arg1, + std::shared_ptr &arg2, std::shared_ptr &arg3, + std::shared_ptr &arg4, std::shared_ptr &arg5); void ScheduleDeallocRenderValues(std::shared_ptr will_dealloc_render_value); private: diff --git a/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp b/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp index 238bfa646..638fecf71 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp @@ -232,18 +232,7 @@ KRRenderCore::OnCallNative(const KuiklyRenderNativeMethod &method, std::shared_p // 判断事件是否需要同步调用 bool KRRenderCore::ShouldSyncCallMethod(const KuiklyRenderNativeMethod &method, std::shared_ptr &arg5) { - if (method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallModuleMethod) { - return IsSyncCallback(arg5); - } - return method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCalculateRenderViewSize || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCreateShadow || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodRemoveShadow || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetShadowProp || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetShadowForView || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSetTimeout || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallShadowMethod || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodSyncFlushUI || - method == KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallTDFNativeMethod; + return KRNativeMethodRequiresContextThread(method, arg5); } KRAnyValue KRRenderCore::PerformNativeCallback(const KuiklyRenderNativeMethod &method, const KRAnyValue &arg1, @@ -446,4 +435,4 @@ void KRRenderCore::notifyInitState(KRInitState state) { derivedPtr->DispatchInitState(state); // 向根View通知初始化事件 } } -} \ No newline at end of file +} From fc57a0e8fd2c9a39aaab770c596cf322f2f4e7a7 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 04:36:56 +0800 Subject: [PATCH 093/187] fix(android): align line-height with CSS font metrics (#22) * fix(android): align line-height with CSS font metrics Signed-off-by: Raft Android Steward * test(android): cover compressed line-height metrics Signed-off-by: Raft Android Steward --------- Signed-off-by: Raft Android Steward Co-authored-by: Raft Android Steward --- .../component/text/KRRichTextBuilder.kt | 24 +++++++-------- .../component/text/HRLineHeightSpanTest.java | 30 ++++++++++++++----- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 63ecee4f0..fcf9acca0 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -826,14 +826,12 @@ class FontFamilySpan(fontFamily: String, typeFaceLoader: TypeFaceLoader?) : Type class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { - // History (Slock task #355 audit): 7b9503d re-based the distribution on - // ascent/descent and b992014 centered on the measured text's ink bounds - // (LineHeightSpan.WithDensity + getTextBounds). Ink-bounds centering made - // the line's vertical position depend on WHICH glyphs are present — the - // composer jumped while typing and static rows with different strings sat - // on different baselines (React's CSS line-height never does this). Both - // are reverted to the content-independent additive centering below - // (0989f41 semantics: even top/bottom split of the extra leading). + // CSS line-height distributes extra leading around the font's ascent and + // descent. Android top/bottom include font-padding extents even when + // StaticLayout.setIncludePad(false), which pushes custom fonts such as + // Space Grotesk below the equivalent browser baseline. Keep this strictly + // metrics-based: glyph-bounds centering makes placement depend on the text + // itself and causes editable content to jump while typing. override fun chooseHeight( text: CharSequence?, start: Int, @@ -842,12 +840,12 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { lineHeight: Int, fm: Paint.FontMetricsInt ) { - val additional: Int = height - (-fm.top + fm.bottom) + val additional: Int = height - (fm.descent - fm.ascent) val topExtra = additional / 2 - fm.top -= topExtra - fm.bottom += additional - topExtra - fm.ascent = fm.top - fm.descent = fm.bottom + fm.ascent -= topExtra + fm.descent += additional - topExtra + fm.top = fm.ascent + fm.bottom = fm.descent } } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java index 066daf11b..5ddca250d 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java @@ -40,10 +40,7 @@ public void oddLineHeightLeadingDoesNotPushBaselineDown() { } @Test - public void lineHeightDistributesFromLineExtentsWhenFontPaddingDiffers() { - // Reverted to top/bottom-based distribution (Slock task #355 audit): - // ascent/descent re-basing (7b9503d) and ink-bounds centering - // (b992014) both made line placement inconsistent across strings. + public void lineHeightDistributesFromCssFontMetricsWhenFontPaddingDiffers() { Paint.FontMetricsInt metrics = new Paint.FontMetricsInt(); metrics.top = -18; metrics.ascent = -13; @@ -52,10 +49,27 @@ public void lineHeightDistributesFromLineExtentsWhenFontPaddingDiffers() { new HRLineHeightSpan(22).chooseHeight("", 0, 0, 0, 0, metrics); - assertEquals(-17, metrics.top); - assertEquals(-17, metrics.ascent); - assertEquals(5, metrics.bottom); - assertEquals(5, metrics.descent); + assertEquals(-15, metrics.top); + assertEquals(-15, metrics.ascent); + assertEquals(7, metrics.bottom); + assertEquals(7, metrics.descent); assertEquals(22, metrics.bottom - metrics.top); } + + @Test + public void compressedLineHeightStillIgnoresFontPaddingExtents() { + Paint.FontMetricsInt metrics = new Paint.FontMetricsInt(); + metrics.top = -18; + metrics.ascent = -13; + metrics.descent = 4; + metrics.bottom = 6; + + new HRLineHeightSpan(14).chooseHeight("", 0, 0, 0, 0, metrics); + + assertEquals(-12, metrics.top); + assertEquals(-12, metrics.ascent); + assertEquals(2, metrics.bottom); + assertEquals(2, metrics.descent); + assertEquals(14, metrics.bottom - metrics.top); + } } From 73fc777afa33aca5d97fc565c8474ebb59b04ef2 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 15:58:12 +0800 Subject: [PATCH 094/187] feat(richtext): add generic inline box span style (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(richtext): add generic inline box span style Signed-off-by: artin * fix(richtext): prefer generic box over legacy chrome Signed-off-by: artin * fix(compose): preserve inline content alternate text Signed-off-by: KMP-专家 * feat(richtext): group styled inline box spans --------- Signed-off-by: artin Signed-off-by: KMP-专家 Co-authored-by: KMP-专家 --- .../foundation/text/KuiklyTextExtension.kt | 129 ++++++- .../text/modifiers/TextStringRichNode.kt | 17 +- .../compose/ui/text/InlineBoxSpanStyle.kt | 38 ++ .../kuikly/compose/ui/text/SpanStyle.kt | 19 + .../kuikly/compose/ui/text/TextStyle.kt | 3 + .../text/InlineBoxGroupLoweringTest.kt | 145 +++++++ .../compose/ui/text/InlineBoxSpanStyleTest.kt | 37 ++ .../expand/component/KRRichTextView.kt | 14 +- .../component/text/KRRichTextBuilder.kt | 342 +++++++++++++++-- .../component/text/KRRichTextViewDrawer.kt | 122 +++++- .../text/KRInlineBoxSpanStyleTest.kt | 37 ++ .../Extension/AdvancedComps/KRRichTextView.h | 2 + .../Extension/AdvancedComps/KRRichTextView.m | 355 +++++++++++++++++- core-render-ios/Extension/Vendor/KRLabel.h | 2 + core-render-ios/Extension/Vendor/KRLabel.m | 103 ++++- .../components/richtext/KRRichTextShadow.cpp | 310 +++++++++++++-- .../components/richtext/KRRichTextShadow.h | 18 +- .../components/richtext/KRRichTextView.cpp | 57 ++- .../components/richtext/KRRichTextView.h | 3 +- .../kuikly/core/views/InlineBoxSpanStyle.kt | 39 ++ .../tencent/kuikly/core/views/RichTextView.kt | 148 +++++++- .../com/tencent/kuikly/core/views/TextView.kt | 10 + docs/API/components/rich-text.md | 83 ++++ 23 files changed, 1917 insertions(+), 116 deletions(-) create mode 100644 compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyle.kt create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyleTest.kt create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt create mode 100644 core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InlineBoxSpanStyle.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 6a0a1ac87..8293d766d 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -44,6 +44,8 @@ import com.tencent.kuikly.compose.ui.unit.isSpecified import com.tencent.kuikly.core.base.Attr import com.tencent.kuikly.core.base.Attr.StyleConst import com.tencent.kuikly.core.views.ISpan +import com.tencent.kuikly.core.views.InlineBoxGroupSpan +import com.tencent.kuikly.core.views.InlineBoxSpanStyle as CoreInlineBoxSpanStyle import com.tencent.kuikly.core.base.BoxShadow import com.tencent.kuikly.core.collection.fastArrayListOf import com.tencent.kuikly.core.collection.fastMutableSetOf @@ -329,15 +331,45 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.end) } - // Collect ParagraphStyle positions - annoText.paragraphStyles.forEach { range -> + // Collect LinkAnnotation positions + val linkAnnotations = annoText.getLinkAnnotations(0, annoText.length) + linkAnnotations.forEach { range -> positions.add(range.start) positions.add(range.end) } - // Collect LinkAnnotation positions - val linkAnnotations = annoText.getLinkAnnotations(0, annoText.length) - linkAnnotations.forEach { range -> + data class InlineBoxRange( + val style: com.tencent.kuikly.compose.ui.text.InlineBoxSpanStyle, + val start: Int, + val end: Int, + ) + + val rawInlineBoxRanges = ( + annoText.spanStyles.mapNotNull { range -> + range.item.inlineBoxStyle?.let { InlineBoxRange(it, range.start, range.end) } + } + + linkAnnotations.mapNotNull { range -> + range.item.styles?.style?.inlineBoxStyle?.let { + InlineBoxRange(it, range.start, range.end) + } + } + ) + require(rawInlineBoxRanges.groupBy { it.start to it.end }.values.all { sameRange -> + sameRange.map { it.style }.distinct().size == 1 + }) { + "Conflicting InlineBoxSpanStyle values on the same range are not supported" + } + val inlineBoxRanges = rawInlineBoxRanges + .distinctBy { it.start to it.end } + .sortedWith(compareBy({ it.start }, { it.end })) + require(inlineBoxRanges.zipWithNext().none { (left, right) -> + right.start < left.end + }) { + "Overlapping InlineBoxSpanStyle ranges are not supported" + } + + // Collect ParagraphStyle positions + annoText.paragraphStyles.forEach { range -> positions.add(range.start) positions.add(range.end) } @@ -378,10 +410,41 @@ internal fun RichTextAttr.applyAnnotatedString( val sortedPositions = positions.sorted() - // Process segments by positions + var activeInlineBoxRange: InlineBoxRange? = null + var activeInlineBoxGroup: InlineBoxGroupSpan? = null + + fun flushInlineBoxGroup() { + activeInlineBoxGroup?.let(spans::add) + activeInlineBoxGroup = null + activeInlineBoxRange = null + } + + // Process segments by positions. An InlineBoxSpanStyle range is preserved + // as one explicit core group instead of being copied onto each flattened + // child span. for (i in 0 until sortedPositions.size - 1) { val start = sortedPositions[i] val end = sortedPositions[i + 1] + val inlineBoxRange = inlineBoxRanges.firstOrNull { range -> + start >= range.start && end <= range.end + } + + if (inlineBoxRange != activeInlineBoxRange) { + flushInlineBoxGroup() + if (inlineBoxRange != null) { + activeInlineBoxRange = inlineBoxRange + activeInlineBoxGroup = InlineBoxGroupSpan( + inlineBoxRange.style.toCoreInlineBoxStyle() + ).apply { + pagerId = this@applyAnnotatedString.pagerId + semanticText( + annoText.text + .substring(inlineBoxRange.start, inlineBoxRange.end) + .replace("\uFFFC", "") + ) + } + } + } // Check if this range is a placeholder val isPlaceholder = placeholders?.any { @@ -391,23 +454,34 @@ internal fun RichTextAttr.applyAnnotatedString( if (isPlaceholder) { // Create PlaceholderSpan placeholders!!.find { it.start == start }?.let { placeholder -> - spans.add(PlaceholderSpan().apply { + val span = PlaceholderSpan().apply { placeholderSize( this@applyAnnotatedString.scaleToDensity(density, placeholder.item.width.value), this@applyAnnotatedString.scaleToDensity(density, placeholder.item.height.value), ) - }) + // Preserve the AnnotatedString alternate text so native + // selection/copy and accessibility do not degrade an + // inline composable to PlaceholderSpan's default space. + description(annoText.text.substring(start, end)) + } + activeInlineBoxGroup?.addChild(span) ?: spans.add(span) } } else if (start < end) { // Create TextSpan for normal text - spans.add(TextSpan().apply { + val span = TextSpan().apply { this.pagerId = this@applyAnnotatedString.pagerId text(annoText.text.substring(start, end)) // Apply SpanStyle annoText.spanStyles .filter { range -> !(end <= range.start || start >= range.end) } - .forEach { range -> applySpanStyle(range.item, density) } + .forEach { range -> + applySpanStyle( + range.item, + density, + includeInlineBox = inlineBoxRange == null, + ) + } if (slockInlineCodeAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCode() @@ -438,8 +512,10 @@ internal fun RichTextAttr.applyAnnotatedString( // Apply LinkAnnotation styles if found linkAnnotation?.let { range -> - val spanStyle = range.item.styles?.style ?: SpanStyle() - applySpanStyle(spanStyle, density) + if (inlineBoxRange == null) { + val spanStyle = range.item.styles?.style ?: SpanStyle() + applySpanStyle(spanStyle, density) + } // Add click event handler click { _ -> @@ -449,9 +525,11 @@ internal fun RichTextAttr.applyAnnotatedString( // Call applyLinkStyle for future extensions applyLinkStyle(range.item) } - }) + } + activeInlineBoxGroup?.addChild(span) ?: spans.add(span) } } + flushInlineBoxGroup() if (spans.isEmpty()) { spans.add(TextSpan().apply { @@ -468,7 +546,11 @@ internal fun TextSpan.applyLinkStyle(link: LinkAnnotation) { } // Helper method to apply SpanStyle -internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { +internal fun TextSpan.applySpanStyle( + spanStyle: SpanStyle, + density: Density, + includeInlineBox: Boolean = true, +) { // Apply font styles if (spanStyle.fontSize.isSpecified) { fontSize(scaleToDensity(density, spanStyle.fontSize.value)) @@ -482,6 +564,11 @@ internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { if (spanStyle.background.isSpecified) { setProp(Attr.StyleConst.BACKGROUND_COLOR, spanStyle.background.toKuiklyColor().toString()) } + if (includeInlineBox) { + spanStyle.inlineBoxStyle?.let { box -> + inlineBoxStyle(box.toCoreInlineBoxStyle()) + } + } if (spanStyle.brush is SolidColor) { color((spanStyle.brush as SolidColor).value.toKuiklyColor()) } else if (spanStyle.brush is LinearGradient) { @@ -513,3 +600,17 @@ internal fun TextSpan.applySpanStyle(spanStyle: SpanStyle, density: Density) { letterSpacing(spanStyle.letterSpacing.value) } } + +private fun com.tencent.kuikly.compose.ui.text.InlineBoxSpanStyle.toCoreInlineBoxStyle() = + CoreInlineBoxSpanStyle( + backgroundColor = backgroundColor.takeIf { it.isSpecified }?.toKuiklyColor(), + borderColor = borderColor.takeIf { it.isSpecified }?.toKuiklyColor(), + borderWidth = borderWidth.value, + paddingStart = paddingStart.value, + paddingEnd = paddingEnd.value, + paddingTop = paddingTop.value, + paddingBottom = paddingBottom.value, + marginStart = marginStart.value, + marginEnd = marginEnd.value, + cornerRadius = cornerRadius.value, + ) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/modifiers/TextStringRichNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/modifiers/TextStringRichNode.kt index e770cbabb..561acca64 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/modifiers/TextStringRichNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/modifiers/TextStringRichNode.kt @@ -62,6 +62,7 @@ import com.tencent.kuikly.compose.ui.unit.constrain import com.tencent.kuikly.core.layout.Frame import com.tencent.kuikly.core.manager.BridgeManager import com.tencent.kuikly.core.views.PlaceholderSpan +import com.tencent.kuikly.core.views.InlineBoxGroupSpan import com.tencent.kuikly.core.views.RichTextAttr import com.tencent.kuikly.core.views.RichTextView import com.tencent.kuikly.core.views.TextConst @@ -233,10 +234,17 @@ internal class TextStringRichNode( val pageDensity = textView!!.getPager().pagerDensity() // 遍历所有文本片段,处理占位符 textView?.getViewAttr()?.getSpans()?.forEachIndexed { index, span -> - if (span !is PlaceholderSpan) return@forEachIndexed - + val placeholders = when (span) { + is PlaceholderSpan -> listOf(null to span) + is InlineBoxGroupSpan -> span.childrenForLayout().mapIndexedNotNull { childIndex, child -> + (child as? PlaceholderSpan)?.let { childIndex to it } + } + else -> emptyList() + } + placeholders.forEach { (childIndex, placeholderSpan) -> // 获取占位符的位置和大小信息 - val rectStr = textView.shadow?.callMethod("spanRect", index.toString()) + val rectTarget = childIndex?.let { "$index $it" } ?: index.toString() + val rectStr = textView.shadow?.callMethod("spanRect", rectTarget) if (rectStr.isNullOrEmpty()) return@forEachIndexed // 解析位置和大小信息 @@ -249,13 +257,14 @@ internal class TextStringRichNode( } // 更新占位符的frame并添加到矩形列表 - span.spanFrame = Frame(x, y, width, height) + placeholderSpan.spanFrame = Frame(x, y, width, height) placeholderRects.add( Rect( offset = Offset(x * pageDensity, y * pageDensity), size = Size(width * pageDensity, height * pageDensity) ) ) + } } val effectiveAnnotated = annotatedText ?: AnnotatedString(plainText ?: "") diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyle.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyle.kt new file mode 100644 index 000000000..d3cca3656 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyle.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.text + +import androidx.compose.runtime.Immutable +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.unit.Dp +import com.tencent.kuikly.compose.ui.unit.dp + +/** Generic box decoration applied to an existing [SpanStyle] range. */ +@Immutable +data class InlineBoxSpanStyle( + val backgroundColor: Color = Color.Unspecified, + val borderColor: Color = Color.Unspecified, + val borderWidth: Dp = 0.dp, + val paddingStart: Dp = 0.dp, + val paddingEnd: Dp = 0.dp, + val paddingTop: Dp = 0.dp, + val paddingBottom: Dp = 0.dp, + val marginStart: Dp = 0.dp, + val marginEnd: Dp = 0.dp, + val cornerRadius: Dp = 0.dp, +) + diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt index b32a70bf5..59937c256 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/SpanStyle.kt @@ -101,6 +101,7 @@ class SpanStyle internal constructor( // val textGeometricTransform: TextGeometricTransform? = null, // val localeList: LocaleList? = null, val background: Color = Color.Unspecified, // kuikly暂时不支持 + val inlineBoxStyle: InlineBoxSpanStyle? = null, val textDecoration: TextDecoration? = null, val shadow: Shadow? = null, val textDecorationColor: Color = Color.Unspecified, @@ -161,6 +162,7 @@ class SpanStyle internal constructor( // textGeometricTransform: TextGeometricTransform? = null, // localeList: LocaleList? = null, background: Color = Color.Unspecified, + inlineBoxStyle: InlineBoxSpanStyle? = null, textDecoration: TextDecoration? = null, shadow: Shadow? = null, textDecorationColor: Color = Color.Unspecified, @@ -181,6 +183,7 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, textDecorationColor = textDecorationColor, @@ -246,6 +249,7 @@ class SpanStyle internal constructor( // textGeometricTransform: TextGeometricTransform? = null, // localeList: LocaleList? = null, background: Color = Color.Unspecified, + inlineBoxStyle: InlineBoxSpanStyle? = null, textDecoration: TextDecoration? = null, shadow: Shadow? = null, textDecorationColor: Color = Color.Unspecified, @@ -266,6 +270,7 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, textDecorationColor = textDecorationColor, @@ -318,6 +323,7 @@ class SpanStyle internal constructor( // textGeometricTransform = other.textGeometricTransform, // localeList = other.localeList, background = other.background, + inlineBoxStyle = other.inlineBoxStyle, textDecoration = other.textDecoration, shadow = other.shadow, textDecorationColor = other.textDecorationColor, @@ -347,6 +353,7 @@ class SpanStyle internal constructor( // textGeometricTransform: TextGeometricTransform? = this.textGeometricTransform, // localeList: LocaleList? = this.localeList, background: Color = this.background, + inlineBoxStyle: InlineBoxSpanStyle? = this.inlineBoxStyle, textDecoration: TextDecoration? = this.textDecoration, shadow: Shadow? = this.shadow, textDecorationColor: Color = this.textDecorationColor, @@ -372,6 +379,7 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, textDecorationColor = textDecorationColor, @@ -396,6 +404,7 @@ class SpanStyle internal constructor( // textGeometricTransform: TextGeometricTransform? = this.textGeometricTransform, // localeList: LocaleList? = this.localeList, background: Color = this.background, + inlineBoxStyle: InlineBoxSpanStyle? = this.inlineBoxStyle, textDecoration: TextDecoration? = this.textDecoration, shadow: Shadow? = this.shadow, textDecorationColor: Color = this.textDecorationColor, @@ -417,6 +426,7 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, textDecorationColor = textDecorationColor, @@ -447,6 +457,7 @@ class SpanStyle internal constructor( // if (textGeometricTransform != other.textGeometricTransform) return false // if (localeList != other.localeList) return false if (background != other.background) return false + if (inlineBoxStyle != other.inlineBoxStyle) return false // if (platformStyle != other.platformStyle) return false return true } @@ -477,6 +488,7 @@ class SpanStyle internal constructor( // result = 31 * result + (textGeometricTransform?.hashCode() ?: 0) // result = 31 * result + (localeList?.hashCode() ?: 0) result = 31 * result + background.hashCode() + result = 31 * result + (inlineBoxStyle?.hashCode() ?: 0) result = 31 * result + (textDecoration?.hashCode() ?: 0) result = 31 * result + textDecorationColor.hashCode() result = 31 * result + textDecorationThickness.hashCode() @@ -499,6 +511,7 @@ class SpanStyle internal constructor( // result = 31 * result + (textGeometricTransform?.hashCode() ?: 0) // result = 31 * result + (localeList?.hashCode() ?: 0) result = 31 * result + background.hashCode() + result = 31 * result + (inlineBoxStyle?.hashCode() ?: 0) // result = 31 * result + (platformStyle?.hashCode() ?: 0) return result } @@ -522,6 +535,7 @@ class SpanStyle internal constructor( // append("textGeometricTransform=$textGeometricTransform, ") // append("localeList=$localeList, ") append("background=$background, ") + append("inlineBoxStyle=$inlineBoxStyle, ") append("textDecoration=$textDecoration, ") append("textDecorationColor=$textDecorationColor, ") append("textDecorationThickness=$textDecorationThickness, ") @@ -612,6 +626,7 @@ fun lerp(start: SpanStyle, stop: SpanStyle, fraction: Float): SpanStyle { stop.background, fraction ), + inlineBoxStyle = lerpDiscrete(start.inlineBoxStyle, stop.inlineBoxStyle, fraction), textDecoration = lerpDiscrete( start.textDecoration, stop.textDecoration, @@ -676,6 +691,7 @@ internal fun resolveSpanStyleDefaults(style: SpanStyle) = SpanStyle( // textGeometricTransform = style.textGeometricTransform ?: TextGeometricTransform.None, // localeList = style.localeList ?: LocaleList.current, background = style.background.takeOrElse { DefaultBackgroundColor }, + inlineBoxStyle = style.inlineBoxStyle, textDecoration = style.textDecoration ?: TextDecoration.None, textDecorationColor = style.textDecorationColor, textDecorationThickness = style.textDecorationThickness, @@ -700,6 +716,7 @@ internal fun SpanStyle.fastMerge( // textGeometricTransform: TextGeometricTransform?, // localeList: LocaleList?, background: Color, + inlineBoxStyle: InlineBoxSpanStyle?, textDecoration: TextDecoration?, shadow: Shadow?, textDecorationColor: Color = Color.Unspecified, @@ -742,6 +759,7 @@ internal fun SpanStyle.fastMerge( // textGeometricTransform != null && textGeometricTransform != this.textGeometricTransform || // localeList != null && localeList != this.localeList || background.isSpecified && background != this.background || + inlineBoxStyle != null && inlineBoxStyle != this.inlineBoxStyle || shadow != null && shadow != this.shadow // || // platformStyle != null && platformStyle != this.platformStyle || @@ -775,6 +793,7 @@ internal fun SpanStyle.fastMerge( // textGeometricTransform = textGeometricTransform ?: this.textGeometricTransform, // localeList = localeList ?: this.localeList, background = background.takeOrElse { this.background }, + inlineBoxStyle = inlineBoxStyle ?: this.inlineBoxStyle, textDecoration = textDecoration ?: this.textDecoration, textDecorationColor = textDecorationColor.takeOrElse { this.textDecorationColor }, textDecorationThickness = if (!textDecorationThickness.isUnspecified) { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/TextStyle.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/TextStyle.kt index d993c1628..5f1670b36 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/TextStyle.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/TextStyle.kt @@ -133,6 +133,7 @@ class TextStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = null, textDecoration = textDecoration, shadow = shadow, // platformStyle = platformStyle?.spanStyle, @@ -236,6 +237,7 @@ class TextStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = null, textDecoration = textDecoration, shadow = shadow, // platformStyle = platformStyle?.spanStyle, @@ -356,6 +358,7 @@ class TextStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = null, textDecoration = textDecoration, shadow = shadow, // platformStyle = platformStyle?.spanStyle, diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt new file mode 100644 index 000000000..967094473 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -0,0 +1,145 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI. + */ + +package com.tencent.kuikly.compose.foundation.text + +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.text.AnnotatedString +import com.tencent.kuikly.compose.ui.text.InlineBoxSpanStyle +import com.tencent.kuikly.compose.ui.text.LinkAnnotation +import com.tencent.kuikly.compose.ui.text.SpanStyle +import com.tencent.kuikly.compose.ui.text.TextLinkStyles +import com.tencent.kuikly.compose.ui.text.font.FontWeight +import com.tencent.kuikly.compose.ui.text.withLink +import com.tencent.kuikly.compose.ui.text.withStyle +import com.tencent.kuikly.compose.ui.unit.Density +import com.tencent.kuikly.compose.ui.unit.dp +import com.tencent.kuikly.core.views.InlineBoxGroupSpan +import com.tencent.kuikly.core.views.InlineBoxSpanStyle as CoreInlineBoxSpanStyle +import com.tencent.kuikly.core.views.PlaceholderSpan +import com.tencent.kuikly.core.views.RichTextAttr +import com.tencent.kuikly.core.views.TextConst +import com.tencent.kuikly.core.views.TextSpan +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class InlineBoxGroupLoweringTest { + + @Test + fun linkStyleRangeLowersToOneGroupWithStyledChildren() { + val box = InlineBoxSpanStyle( + backgroundColor = Color.Yellow, + borderColor = Color.Black, + borderWidth = 1.dp, + paddingStart = 4.dp, + paddingEnd = 4.dp, + paddingTop = 1.dp, + paddingBottom = 1.dp, + marginStart = 2.dp, + marginEnd = 2.dp, + ) + val builder = AnnotatedString.Builder() + builder.append("before ") + builder.withLink( + LinkAnnotation.Url( + url = "https://example.test/message", + styles = TextLinkStyles(style = SpanStyle(inlineBoxStyle = box)), + ) + ) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("#proj") + } + append(" ") + withStyle(SpanStyle(color = Color.Gray)) { + append("msg") + } + } + + val attr = RichTextAttr() + attr.applyAnnotatedString( + annoText = builder.toAnnotatedString(), + density = Density(1f), + ) + + val spans = attr.getSpans() + assertEquals(2, spans.size) + assertEquals("before ", assertIs(spans[0]).getText()) + val group = assertIs(spans[1]) + val children = group.childrenForLayout() + assertEquals(3, children.size) + val label = assertIs(children[0]) + val suffix = assertIs(children[2]) + + assertEquals("#proj", label.getText()) + assertEquals("700", label.spanPropsMap()[TextConst.FONT_WEIGHT]) + assertEquals("msg", suffix.getText()) + + val props = group.spanPropsMap() + assertEquals("#proj msg", props[InlineBoxGroupSpan.PROP_KEY_SEMANTIC_TEXT]) + } + + @Test + fun groupSerializationKeepsNestedPlaceholderPathsAndChildTypography() { + val group = InlineBoxGroupSpan(CoreInlineBoxSpanStyle(borderWidth = 1f)).apply { + semanticText("#proj msg") + addChild(PlaceholderSpan().apply { placeholderSize(12f, 12f) }) + addChild(TextSpan().apply { + text("#proj") + setProp(TextConst.FONT_SIZE, 14f) + fontWeightBold() + }) + addChild(PlaceholderSpan().apply { placeholderSize(6f, 1f) }) + addChild(TextSpan().apply { + text("msg") + setProp(TextConst.FONT_SIZE, 10f) + }) + } + + @Suppress("UNCHECKED_CAST") + val children = group.spanPropsMap()[InlineBoxGroupSpan.PROP_KEY_CHILDREN] as List> + assertEquals(12f, children[0][PlaceholderSpan.PROP_KEY_PLACEHOLDER_WIDTH]) + assertEquals(14f, children[1][TextConst.FONT_SIZE]) + assertEquals("700", children[1][TextConst.FONT_WEIGHT]) + assertEquals(6f, children[2][PlaceholderSpan.PROP_KEY_PLACEHOLDER_WIDTH]) + assertEquals(10f, children[3][TextConst.FONT_SIZE]) + } + + @Test + fun overlappingInlineBoxRangesFailFast() { + val box = InlineBoxSpanStyle(backgroundColor = Color.Yellow) + val text = AnnotatedString.Builder("abcdef").apply { + addStyle(SpanStyle(inlineBoxStyle = box), 0, 4) + addStyle(SpanStyle(inlineBoxStyle = box), 2, 6) + }.toAnnotatedString() + + assertFailsWith { + RichTextAttr().applyAnnotatedString(text, density = Density(1f)) + } + } + + @Test + fun conflictingInlineBoxStylesOnSameRangeFailFast() { + val text = AnnotatedString.Builder("chip").apply { + addStyle( + SpanStyle(inlineBoxStyle = InlineBoxSpanStyle(backgroundColor = Color.Yellow)), + 0, + 4, + ) + addStyle( + SpanStyle(inlineBoxStyle = InlineBoxSpanStyle(backgroundColor = Color.Red)), + 0, + 4, + ) + }.toAnnotatedString() + + assertFailsWith { + RichTextAttr().applyAnnotatedString(text, density = Density(1f)) + } + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyleTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyleTest.kt new file mode 100644 index 000000000..d51fc576e --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/text/InlineBoxSpanStyleTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package com.tencent.kuikly.compose.ui.text + +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +class InlineBoxSpanStyleTest { + + @Test + fun mergeCarriesInlineBoxStyleOnExistingSpanStyle() { + val box = InlineBoxSpanStyle( + backgroundColor = Color.Yellow, + borderColor = Color.Black, + borderWidth = 1.dp, + paddingStart = 4.dp, + paddingEnd = 5.dp, + ) + + val merged = SpanStyle(color = Color.Red).merge(SpanStyle(inlineBoxStyle = box)) + + assertEquals(Color.Red, merged.color) + assertEquals(box, merged.inlineBoxStyle) + assertEquals(box, merged.copy().inlineBoxStyle) + } +} + diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt index 843e2d8a2..43d2bc9b6 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt @@ -599,8 +599,10 @@ class KRRichTextShadow : IKuiklyRenderShadowExport, IKuiklyRenderContextWrapper override fun call(methodName: String, params: String): Any? { when(methodName) { METHOD_GET_PLACEHOLDER_SPAN_RECT -> { - val index = params.toInt() - val spanRect = getPlaceholderSpanRect(index) + val path = params.split(" ") + val index = path.firstOrNull()?.toIntOrNull() ?: -1 + val childIndex = path.getOrNull(1)?.toIntOrNull() + val spanRect = getPlaceholderSpanRect(index, childIndex) return "${spanRect.left} ${spanRect.top} ${spanRect.width()} ${spanRect.height()}" } METHOD_IS_LINE_BREAK_MARGIN -> { @@ -613,10 +615,12 @@ class KRRichTextShadow : IKuiklyRenderShadowExport, IKuiklyRenderContextWrapper /** * 根据 index 获取 PlaceholderSpan 的绘制区域 */ - private fun getPlaceholderSpanRect(index: Int) : Rect { + private fun getPlaceholderSpanRect(index: Int, childIndex: Int? = null) : Rect { var rect = Rect(0, 0, 0, 0) textDrawer?.textLayout?.let { layout -> - var phSpanTextRange: SpanTextRange? = spanTextRanges.find { it.index == index } + val phSpanTextRange: SpanTextRange? = spanTextRanges.find { + it.index == index && it.childIndex == childIndex + } if (phSpanTextRange != null) { @@ -940,4 +944,4 @@ data class SelectionEdge( val x: Float, val top: Float, val bottom: Float -) \ No newline at end of file +) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index fcf9acca0..7a887fc65 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -69,6 +69,8 @@ private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_RATIO = 1f / 15f // run looks identical on one line and only wraps when it must. Short runs stay a // single atom, unchanged. private const val SLOCK_INLINE_CODE_LONG_RUN_THRESHOLD = 16 +internal const val INLINE_BOX_LAYOUT_JOINER = '\u2060' +private const val INLINE_BOX_LAYOUT_EDGE = '\uFFFC' /** * 富文本构造器 @@ -97,40 +99,22 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { spannedBuilder.isEmpty() || spannedBuilder[spannedBuilder.lastIndex] == '\n' val spanValue = spanValues.optJSONObject(index) ?: JSONObject() val spanProps = parseSpanProps(spanValue, textProps, isStart) - val spans = createSpans(spanProps, index, layoutSizeGetter) - if (spans.isNotEmpty()) { - if (spanProps is TextSpanProps && spanProps.adjustNewline) { - // 对齐iOS、鸿蒙端表现,非空行的换行符不撑开行高 - spannedBuilder.append( - "\n", - AbsoluteSizeSpan(1), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - val spanStart = spannedBuilder.length - val spanText = spanProps.text - val spanEnd = spanStart + spanText.length - spanTextRanges.add( - SpanTextRange( - index, - spanStart, - spanEnd - ) + if (spanProps is InlineBoxGroupSpanProps) { + spannedBuilder.appendInlineBoxGroup( + groupProps = spanProps, + index = index, + defaultTextProps = textProps, + spanTextRanges = spanTextRanges, + layoutSizeGetter = layoutSizeGetter, + ) + } else { + spannedBuilder.appendSpan( + spanProps = spanProps, + index = index, + childIndex = null, + spanTextRanges = spanTextRanges, + layoutSizeGetter = layoutSizeGetter, ) - spannedBuilder.append(buildSpannedString { - inSpans(spans) { - append(spanText) - } - }) - if (spanProps is TextSpanProps && spanProps.slockInlineCode) { - spannedBuilder.applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) - } - if ( - spanProps is TextSpanProps && - spanProps.slockMarkdownTagChrome.isSlockMarkdownTagChipChrome() - ) { - spannedBuilder.applySlockMarkdownTagAtomicTextSpan(spanStart, spanEnd) - } } } if (textProps.richTextHeadIndent != 0) { @@ -152,6 +136,9 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { defaultTextProps: KRTextProps, isStart: Boolean ): SpanProps { + if (spanValue.has(InlineBoxGroupSpanProps.PROP_KEY_CHILDREN)) { + return InlineBoxGroupSpanProps(spanValue, defaultTextProps, kuiklyContext) + } if (isPlaceHolderSpan(spanValue)) { return PlaceholderSpanProps(spanValue, kuiklyContext) } @@ -221,7 +208,8 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { textSpans.add(ForegroundColorSpan(spanProps.color)) if (spanProps.backgroundColor != Color.TRANSPARENT && !spanProps.slockInlineCode && - spanProps.slockMarkdownTagChrome == null + spanProps.slockMarkdownTagChrome == null && + spanProps.inlineBoxStyle == null ) { textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) } @@ -253,8 +241,13 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.slockInlineCodeTrailingMargin) { textSpans.add(KRSlockInlineCodeTrailingMarginSpan()) } - spanProps.slockMarkdownTagChrome?.let { kind -> - textSpans.add(KRSlockMarkdownTagSpan(kind)) + if (spanProps.inlineBoxStyle == null) { + spanProps.slockMarkdownTagChrome?.let { kind -> + textSpans.add(KRSlockMarkdownTagSpan(kind)) + } + } + spanProps.inlineBoxStyle?.let { style -> + textSpans.add(KRInlineBoxSpan(style)) } spanProps.textShadow?.let { @@ -280,6 +273,106 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { return textSpans } + private fun SpannableStringBuilder.appendSpan( + spanProps: SpanProps, + index: Int, + childIndex: Int?, + spanTextRanges: MutableList, + layoutSizeGetter: () -> SizeF, + ) { + val spans = createSpans(spanProps, index, layoutSizeGetter) + if (spans.isEmpty()) return + if (spanProps is TextSpanProps && spanProps.adjustNewline) { + append("\n", AbsoluteSizeSpan(1), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val spanStart = length + val spanText = spanProps.text + val spanEnd = spanStart + spanText.length + spanTextRanges.add(SpanTextRange(index, childIndex, spanStart, spanEnd)) + append(buildSpannedString { + inSpans(spans) { append(spanText) } + }) + if (spanProps is TextSpanProps && spanProps.slockInlineCode) { + applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) + } + if ( + spanProps is TextSpanProps && + spanProps.inlineBoxStyle == null && + spanProps.slockMarkdownTagChrome.isSlockMarkdownTagChipChrome() + ) { + applySlockMarkdownTagAtomicTextSpan(spanStart, spanEnd) + } + if (spanProps is TextSpanProps && spanProps.inlineBoxStyle != null) { + applyInlineBoxAtomicTextSpan(spanStart, spanEnd, spanProps.inlineBoxStyle) + } + } + + private fun SpannableStringBuilder.appendInlineBoxGroup( + groupProps: InlineBoxGroupSpanProps, + index: Int, + defaultTextProps: KRTextProps, + spanTextRanges: MutableList, + layoutSizeGetter: () -> SizeF, + ) { + val children = buildList { + for (childIndex in 0 until groupProps.children.length()) { + val childValue = groupProps.children.optJSONObject(childIndex) ?: continue + val childProps = parseSpanProps( + childValue, + defaultTextProps, + isStart = isEmpty() || this@appendInlineBoxGroup[lastIndex] == '\n', + ) + if (childProps.text.isNotEmpty()) add(childIndex to childProps) + } + } + val groupStart = length + append( + INLINE_BOX_LAYOUT_EDGE.toString(), + KRInlineBoxEdgeAdvanceSpan( + advance = groupProps.style.leadingAdvance, + paddingTop = groupProps.style.paddingTop, + paddingBottom = groupProps.style.paddingBottom, + ), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + children.forEachIndexed { position, (childIndex, childProps) -> + append(INLINE_BOX_LAYOUT_JOINER) + appendSpan( + spanProps = childProps, + index = index, + childIndex = childIndex, + spanTextRanges = spanTextRanges, + layoutSizeGetter = layoutSizeGetter, + ) + } + append(INLINE_BOX_LAYOUT_JOINER) + append( + INLINE_BOX_LAYOUT_EDGE.toString(), + KRInlineBoxEdgeAdvanceSpan( + advance = groupProps.style.trailingAdvance, + paddingTop = groupProps.style.paddingTop, + paddingBottom = groupProps.style.paddingBottom, + ), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val groupEnd = length + if (groupEnd > groupStart) { + setSpan( + KRInlineBoxSpan(groupProps.style), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + setSpan( + KRInlineBoxSemanticSpan(groupProps.semanticText), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + spanTextRanges.add(SpanTextRange(index, null, groupStart, groupEnd)) + } + } + } abstract class SpanProps(spanValue: JSONObject) { @@ -311,6 +404,7 @@ class TextSpanProps( val slockInlineCode: Boolean val slockInlineCodeTrailingMargin: Boolean val slockMarkdownTagChrome: String? + val inlineBoxStyle: KRInlineBoxSpanStyle? var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -383,6 +477,7 @@ class TextSpanProps( slockMarkdownTagChrome = spanValue.optString(TextConst.SLOCK_MARKDOWN_TAG_CHROME, "") .takeIf { it.isNotEmpty() } + inlineBoxStyle = KRInlineBoxSpanStyle.from(spanValue, kuiklyContext) val textShadowStr = spanValue.optString(KRTextProps.PROP_KEY_TEXT_SHADOW, "") textShadow = BoxShadow(textShadowStr, kuiklyContext) useDpFontSizeDim = spanValue.optInt(KRTextProps.PROP_KEY_TEXT_USE_DP_FONT_SIZE_DIM) == 1 @@ -410,15 +505,126 @@ class PlaceholderSpanProps(spanValue: JSONObject, private val kuiklyContext: IKu /** * 用于记录 DSL Span 对应的 Text Range */ -data class SpanTextRange(val index: Int, val start: Int, val end: Int) { +data class SpanTextRange( + val index: Int, + val childIndex: Int?, + val start: Int, + val end: Int, +) { override fun toString(): String { return "{$index, $start, $end}" } } +class InlineBoxGroupSpanProps( + spanValue: JSONObject, + defaultProps: KRTextProps, + kuiklyContext: IKuiklyRenderContext?, +) : SpanProps(spanValue) { + companion object { + const val PROP_KEY_CHILDREN = "inlineBoxChildren" + private const val PROP_KEY_SEMANTIC_TEXT = "inlineBoxSemanticText" + } + + val children = spanValue.optJSONArray(PROP_KEY_CHILDREN) ?: org.json.JSONArray() + val semanticText = spanValue.optString(PROP_KEY_SEMANTIC_TEXT, "") + val style = checkNotNull(KRInlineBoxSpanStyle.from(spanValue, kuiklyContext)) +} + class KRSlockInlineCodeSpan class KRSlockMarkdownTagSpan(val kind: String) +data class KRInlineBoxSpanStyle( + val backgroundColor: Int?, + val borderColor: Int?, + val borderWidth: Float, + val paddingStart: Float, + val paddingEnd: Float, + val paddingTop: Float, + val paddingBottom: Float, + val marginStart: Float, + val marginEnd: Float, + val cornerRadius: Float, +) { + val leadingAdvance: Float + get() = marginStart + borderWidth + paddingStart + + val trailingAdvance: Float + get() = paddingEnd + borderWidth + marginEnd + + companion object { + fun from(value: JSONObject, context: IKuiklyRenderContext?): KRInlineBoxSpanStyle? { + val hasStyle = value.has(TextConst.INLINE_BOX_BACKGROUND_COLOR) || + value.has(TextConst.INLINE_BOX_BORDER_COLOR) || + value.has(TextConst.INLINE_BOX_BORDER_WIDTH) || + value.has(TextConst.INLINE_BOX_PADDING_START) || + value.has(TextConst.INLINE_BOX_PADDING_END) || + value.has(TextConst.INLINE_BOX_PADDING_TOP) || + value.has(TextConst.INLINE_BOX_PADDING_BOTTOM) || + value.has(TextConst.INLINE_BOX_MARGIN_START) || + value.has(TextConst.INLINE_BOX_MARGIN_END) || + value.has(TextConst.INLINE_BOX_CORNER_RADIUS) + if (!hasStyle) return null + fun color(key: String): Int? = value.optString(key).takeIf { it.isNotEmpty() }?.toColor() + fun dimension(key: String): Float { + val logicalPx = value.optDouble(key, 0.0).toFloat() + return if (logicalPx == 0f) 0f else context.toPxF(logicalPx) + } + return KRInlineBoxSpanStyle( + backgroundColor = color(TextConst.INLINE_BOX_BACKGROUND_COLOR), + borderColor = color(TextConst.INLINE_BOX_BORDER_COLOR), + borderWidth = dimension(TextConst.INLINE_BOX_BORDER_WIDTH), + paddingStart = dimension(TextConst.INLINE_BOX_PADDING_START), + paddingEnd = dimension(TextConst.INLINE_BOX_PADDING_END), + paddingTop = dimension(TextConst.INLINE_BOX_PADDING_TOP), + paddingBottom = dimension(TextConst.INLINE_BOX_PADDING_BOTTOM), + marginStart = dimension(TextConst.INLINE_BOX_MARGIN_START), + marginEnd = dimension(TextConst.INLINE_BOX_MARGIN_END), + cornerRadius = dimension(TextConst.INLINE_BOX_CORNER_RADIUS), + ) + } + } +} + +class KRInlineBoxSpan(val style: KRInlineBoxSpanStyle) +class KRInlineBoxSemanticSpan(val text: String) + +private class KRInlineBoxEdgeAdvanceSpan( + private val advance: Float, + private val paddingTop: Float = 0f, + private val paddingBottom: Float = 0f, +) : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int { + fm?.let { + it.ascent -= ceil(paddingTop).toInt() + it.top -= ceil(paddingTop).toInt() + it.descent += ceil(paddingBottom).toInt() + it.bottom += ceil(paddingBottom).toInt() + } + return ceil(advance).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) { + Unit + } +} + internal const val SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION = "ordinaryMention" internal fun String?.isSlockMarkdownTagChipChrome(): Boolean = @@ -509,6 +715,61 @@ private fun SpannableStringBuilder.applySlockMarkdownTagAtomicTextSpan(start: In } } +private fun SpannableStringBuilder.applyInlineBoxAtomicTextSpan( + start: Int, + end: Int, + style: KRInlineBoxSpanStyle, +) { + if (start < end) { + setSpan( + KRInlineBoxAtomicTextSpan(style), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } +} + +private class KRInlineBoxAtomicTextSpan( + private val style: KRInlineBoxSpanStyle, +) : ReplacementSpan() { + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (text == null || start >= end) return 0 + fm?.let { + it.ascent -= ceil(style.paddingTop).toInt() + it.top -= ceil(style.paddingTop).toInt() + it.descent += ceil(style.paddingBottom).toInt() + it.bottom += ceil(style.paddingBottom).toInt() + } + val edgeStart = style.marginStart + style.borderWidth + style.paddingStart + val edgeEnd = style.paddingEnd + style.borderWidth + style.marginEnd + return ceil((paint.measureText(text, start, end) + edgeStart + edgeEnd).toDouble()).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + if (text != null && start < end) { + val textX = x + style.marginStart + style.borderWidth + style.paddingStart + canvas.drawText(text, start, end, textX, y.toFloat(), paint) + } + } +} + private class KRSlockMarkdownTagAtomicTextSpan : ReplacementSpan() { override fun getSize( @@ -713,14 +974,17 @@ private class KRSlockInlineCodeAtomicTextSpan( */ class FontWeightSpan(fontWeight: String, val index: Int = -1) : CharacterStyle() { + private val requestedWeight = fontWeight.toIntOrNull() ?: FONT_WEIGHT_NORMAL.toInt() private val strokeWidth = getFontWeight(fontWeight) private val fakeBold = isBoldWeight(fontWeight) override fun updateDrawState(tp: TextPaint) { - if (fakeBold) { + val nativeTypefaceSatisfiesWeight = + requestedWeight == FONT_WEIGHT_BOLD.toInt() && tp.typeface?.isBold == true + if (fakeBold && !nativeTypefaceSatisfiesWeight) { tp.isFakeBoldText = true } - if (strokeWidth != 0f) { + if (strokeWidth != 0f && !nativeTypefaceSatisfiesWeight) { tp.style = Paint.Style.FILL_AND_STROKE tp.strokeWidth = strokeWidth * tp.textSize } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index cf8337c17..1b95bd638 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -89,6 +89,13 @@ class KRRichTextViewDrawer(val textLayout: Layout) { isAntiAlias = false } private val slockMarkdownTagRect = RectF() + private val inlineBoxFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + private val inlineBoxBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } + private val inlineBoxRect = RectF() private val wordIterator by lazy(LazyThreadSafetyMode.NONE) { WordIterator(textLayout.text, 0, textLayout.text.length, Locale.getDefault()) @@ -112,11 +119,87 @@ class KRRichTextViewDrawer(val textLayout: Layout) { * 将文本内容绘制到 [canvas],对接到 [Layout.draw]。 */ fun draw(canvas: Canvas) { + drawInlineBoxChrome(canvas, drawFill = true, drawBorder = false) drawSlockInlineCodeChrome(canvas, drawFill = true, drawBorder = false) drawSlockMarkdownTagChrome(canvas, drawFill = true, drawBorder = false) textLayout.draw(canvas) drawSlockInlineCodeChrome(canvas, drawFill = false, drawBorder = true) drawSlockMarkdownTagChrome(canvas, drawFill = false, drawBorder = true) + drawInlineBoxChrome(canvas, drawFill = false, drawBorder = true) + } + + private fun drawInlineBoxChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { + val spanned = textLayout.text as? Spanned ?: return + val spans = spanned.getSpans(0, spanned.length, KRInlineBoxSpan::class.java) + if (spans.isEmpty()) return + + val layoutLeft = 0f + val layoutRight = textLayout.width.toFloat() + val metrics = textLayout.paint.fontMetrics + spans.forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + if (start < 0 || end <= start) return@forEach + val style = span.style + val startLine = textLayout.getLineForOffset((start + 1).coerceAtMost(end - 1)) + val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) + for (line in startLine..endLine) { + val lineStart = textLayout.getLineStart(line) + val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) + val segmentStart = max(start, lineStart) + val segmentEnd = min(end, lineVisibleEnd) + if (segmentEnd <= segmentStart) continue + val startX = max( + textLayout.getPrimaryHorizontal(segmentStart), + textLayout.getSecondaryHorizontal(segmentStart), + ) + // At a run boundary Android's primary caret may use downstream + // affinity and jump across the following span. The upstream + // caret is the actual visual end of this inline group. + val endX = min( + textLayout.getPrimaryHorizontal(segmentEnd), + textLayout.getSecondaryHorizontal(segmentEnd), + ) + val segmentLeft = min(startX, endX) + val segmentRight = max(startX, endX) + val left = ( + segmentLeft + if (segmentStart == start) style.marginStart else 0f + ) + .coerceAtLeast(layoutLeft) + val right = ( + segmentRight - if (segmentEnd == end) style.marginEnd else 0f + ) + .coerceAtMost(layoutRight) + if (right <= left) continue + + val baseline = textLayout.getLineBaseline(line).toFloat() + val top = baseline + metrics.ascent - style.paddingTop - style.borderWidth + val bottom = baseline + metrics.descent + style.paddingBottom + style.borderWidth + if (bottom <= top) continue + inlineBoxRect.set(left, top, right, bottom) + if (drawFill && style.backgroundColor != null) { + inlineBoxFillPaint.color = style.backgroundColor + canvas.drawRoundRect( + inlineBoxRect, + style.cornerRadius, + style.cornerRadius, + inlineBoxFillPaint + ) + } + if (drawBorder && style.borderColor != null && style.borderWidth > 0f) { + inlineBoxBorderPaint.color = style.borderColor + inlineBoxBorderPaint.strokeWidth = style.borderWidth + val inset = style.borderWidth / 2f + inlineBoxRect.inset(inset, inset) + canvas.drawRoundRect( + inlineBoxRect, + max(0f, style.cornerRadius - inset), + max(0f, style.cornerRadius - inset), + inlineBoxBorderPaint + ) + } + } + } } private fun drawSlockMarkdownTagChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { @@ -454,7 +537,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { internal fun getSelectionText(): String? { return if (hasSelection) { - textLayout.text.substring(selectionStart, selectionEnd) + textLayout.text.inlineBoxSemanticSubstring(selectionStart, selectionEnd) } else { null } @@ -462,7 +545,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { internal fun getPreSelectionText(): String? { return if (hasSelection && selectionStart > 0) { - textLayout.text.substring(0, selectionStart) + textLayout.text.inlineBoxSemanticSubstring(0, selectionStart) } else { null } @@ -471,7 +554,7 @@ class KRRichTextViewDrawer(val textLayout: Layout) { internal fun getPostSelectionText(): String? { val length = textLayout.text.length return if (hasSelection && selectionEnd < length) { - textLayout.text.substring(selectionEnd, length) + textLayout.text.inlineBoxSemanticSubstring(selectionEnd, length) } else { null } @@ -723,3 +806,36 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } } + +private fun String.withoutInlineBoxLayoutCharacters(): String = + replace("\uFFFC", "").replace(INLINE_BOX_LAYOUT_JOINER.toString(), "") + +private fun CharSequence.inlineBoxSemanticSubstring(start: Int, end: Int): String { + if (start >= end) return "" + val spanned = this as? Spanned + ?: return substring(start, end).withoutInlineBoxLayoutCharacters() + val semanticSpans = spanned.getSpans(start, end, KRInlineBoxSemanticSpan::class.java) + if (semanticSpans.isEmpty()) return substring(start, end).withoutInlineBoxLayoutCharacters() + + val result = StringBuilder() + var cursor = start + semanticSpans.sortedBy(spanned::getSpanStart).forEach { span -> + val spanStart = spanned.getSpanStart(span) + val spanEnd = spanned.getSpanEnd(span) + if (spanStart > cursor) { + result.append(substring(cursor, min(spanStart, end)).withoutInlineBoxLayoutCharacters()) + } + val overlapStart = max(cursor, spanStart) + val overlapEnd = min(end, spanEnd) + if (overlapEnd > overlapStart) { + if (overlapStart == spanStart && overlapEnd == spanEnd && span.text.isNotEmpty()) { + result.append(span.text) + } else { + result.append(substring(overlapStart, overlapEnd).withoutInlineBoxLayoutCharacters()) + } + cursor = overlapEnd + } + } + if (cursor < end) result.append(substring(cursor, end).withoutInlineBoxLayoutCharacters()) + return result.toString() +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt new file mode 100644 index 000000000..f773e2fb0 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt @@ -0,0 +1,37 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + */ + +package com.tencent.kuikly.core.render.android.expand.component.text + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class KRInlineBoxSpanStyleTest { + + @Test + fun absentStyleDoesNotCreateRendererDecoration() { + assertNull(KRInlineBoxSpanStyle.from(JSONObject(), null)) + } + + @Test + fun rendererReadsStyleValuesWithoutSemanticKind() { + val value = JSONObject() + .put("inlineBoxBorderWidth", 0) + .put("inlineBoxPaddingStart", 0) + .put("inlineBoxPaddingEnd", 0) + + val style = KRInlineBoxSpanStyle.from(value, null) + + assertNotNull(style) + assertEquals(0f, style!!.borderWidth) + assertEquals(0f, style.paddingStart) + assertEquals(0f, style.paddingEnd) + } +} diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h index c7968e44b..f257844cf 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h @@ -67,6 +67,8 @@ extern NSString *const KuiklyIndexAttributeName; // Slock rich-text chip chrome kind (task #439): chrome-kind wire string when this // span is an inline-code / tag chip, else nil. Consumed by KRLayoutManager. @property (nonatomic, copy, nullable) NSString *slockChrome; +// Generic semantic-free inline box decoration carried by the existing TextSpan. +@property (nonatomic, strong, nullable) NSDictionary *inlineBoxStyle; @end diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index e6fe408d0..f94e70c74 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -145,6 +145,148 @@ - (NSString *)kr_originlTextBeforeTextAttachment { @end +@interface KRInlineBoxAttachment : NSTextAttachment + +@property (nonatomic, copy) NSString *originalText; + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + style:(NSDictionary *)style + letterSpacing:(CGFloat)letterSpacing; + +@end + +@implementation KRInlineBoxAttachment + +- (instancetype)initWithText:(NSString *)text + font:(UIFont *)font + textColor:(UIColor *)textColor + style:(NSDictionary *)style + letterSpacing:(CGFloat)letterSpacing { + if (self = [super init]) { + _originalText = [text copy] ?: @""; + UIFont *resolvedFont = font ?: [UIFont systemFontOfSize:15.0]; + UIColor *resolvedTextColor = textColor ?: [UIColor blackColor]; + UIColor *backgroundColor = style[@"backgroundColor"] ?: [UIColor clearColor]; + UIColor *borderColor = style[@"borderColor"] ?: [UIColor clearColor]; + CGFloat borderWidth = [style[@"borderWidth"] doubleValue]; + CGFloat paddingStart = [style[@"paddingStart"] doubleValue]; + CGFloat paddingEnd = [style[@"paddingEnd"] doubleValue]; + CGFloat paddingTop = [style[@"paddingTop"] doubleValue]; + CGFloat paddingBottom = [style[@"paddingBottom"] doubleValue]; + CGFloat marginStart = [style[@"marginStart"] doubleValue]; + CGFloat marginEnd = [style[@"marginEnd"] doubleValue]; + CGFloat cornerRadius = [style[@"cornerRadius"] doubleValue]; + NSMutableDictionary *attributes = [@{ + NSFontAttributeName: resolvedFont, + NSForegroundColorAttributeName: resolvedTextColor, + } mutableCopy]; + if (letterSpacing != 0) { + attributes[NSKernAttributeName] = @(letterSpacing); + } + NSAttributedString *displayText = [[NSAttributedString alloc] initWithString:_originalText attributes:attributes]; + CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)displayText); + CGFloat ascent = 0; + CGFloat descent = 0; + CGFloat leading = 0; + CGFloat textWidth = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); + CGFloat contentHeight = ascent + descent; + CGFloat boxHeight = contentHeight + paddingTop + paddingBottom + borderWidth * 2.0; + CGFloat totalWidth = textWidth + marginStart + marginEnd + paddingStart + paddingEnd + borderWidth * 2.0; + CGFloat boxLeft = marginStart; + CGFloat boxWidth = totalWidth - marginStart - marginEnd; + + UIGraphicsBeginImageContextWithOptions(CGSizeMake(totalWidth, boxHeight), NO, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context) { + CGRect boxRect = CGRectMake(boxLeft, 0, boxWidth, boxHeight); + CGPathRef boxPath = CGPathCreateWithRoundedRect(boxRect, cornerRadius, cornerRadius, NULL); + CGContextAddPath(context, boxPath); + CGContextSetFillColorWithColor(context, backgroundColor.CGColor); + CGContextFillPath(context); + if (borderWidth > 0 && borderColor) { + CGRect strokeRect = CGRectInset(boxRect, borderWidth / 2.0, borderWidth / 2.0); + CGPathRef strokePath = CGPathCreateWithRoundedRect( + strokeRect, + MAX(0, cornerRadius - borderWidth / 2.0), + MAX(0, cornerRadius - borderWidth / 2.0), + NULL + ); + CGContextAddPath(context, strokePath); + CGContextSetStrokeColorWithColor(context, borderColor.CGColor); + CGContextSetLineWidth(context, borderWidth); + CGContextStrokePath(context); + CGPathRelease(strokePath); + } + CGPathRelease(boxPath); + + CGContextSaveGState(context); + CGContextTranslateCTM(context, 0, boxHeight); + CGContextScaleCTM(context, 1.0, -1.0); + CGContextSetTextMatrix(context, CGAffineTransformIdentity); + CGContextSetTextPosition( + context, + marginStart + borderWidth + paddingStart, + borderWidth + paddingBottom + descent + ); + CTLineDraw(line, context); + CGContextRestoreGState(context); + } + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + CFRelease(line); + + self.image = image; + CGFloat baselineOffset = (resolvedFont.ascender + resolvedFont.descender) / 2.0 - boxHeight / 2.0; + self.bounds = CGRectMake(0, baselineOffset, totalWidth, boxHeight); + } + return self; +} + +- (NSString *)kr_originlTextBeforeTextAttachment { + return self.originalText ?: @""; +} + +@end + +@interface KRInlineBoxEdgeAttachment : NSTextAttachment +- (instancetype)initWithAdvance:(CGFloat)advance + font:(UIFont *)font + paddingTop:(CGFloat)paddingTop + paddingBottom:(CGFloat)paddingBottom + borderWidth:(CGFloat)borderWidth; +@end + +@implementation KRInlineBoxEdgeAttachment + +- (instancetype)initWithAdvance:(CGFloat)advance + font:(UIFont *)font + paddingTop:(CGFloat)paddingTop + paddingBottom:(CGFloat)paddingBottom + borderWidth:(CGFloat)borderWidth { + if (self = [super init]) { + UIFont *resolvedFont = font ?: [UIFont systemFontOfSize:15.0]; + CGFloat height = resolvedFont.ascender - resolvedFont.descender + paddingTop + paddingBottom + borderWidth * 2.0; + CGFloat resolvedWidth = MAX(0, advance); + CGFloat resolvedHeight = MAX(1, height); + // TextKit may render a nil-image attachment as an opaque placeholder. + // Edge attachments are layout-only advance; give them an explicit + // transparent bitmap so the group chrome painted behind remains visible. + UIGraphicsBeginImageContextWithOptions(CGSizeMake(MAX(1, resolvedWidth), resolvedHeight), NO, 0.0); + self.image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + self.bounds = CGRectMake(0, resolvedFont.descender - paddingBottom, resolvedWidth, resolvedHeight); + } + return self; +} + +- (NSString *)kr_originlTextBeforeTextAttachment { + return @""; +} + +@end + // Inline code uses the same atomic inline-box model as reference chips, at a // finer granularity: one attachment per composed grapheme. Each atom owns its // glyph measurement/drawing and original text, while KRLayoutManager paints one @@ -527,7 +669,16 @@ - (NSMutableAttributedString *)p_buildAttributedString { NSString *textPostProcessor = nil; NSMutableArray *richAttrArray = [NSMutableArray new]; UIFont *mainFont = nil; - for (NSMutableDictionary * span in spans) { + for (NSInteger spanIndex = 0; spanIndex < spans.count; spanIndex++) { + NSMutableDictionary *span = spans[spanIndex]; + if ([span[@"inlineBoxChildren"] isKindOfClass:[NSArray class]]) { + NSAttributedString *group = [self p_createInlineBoxGroupAttributedStringWithSpan:span + spanIndex:spanIndex]; + if (group.length > 0) { + [richAttrArray addObject:group]; + } + continue; + } if (span[@"placeholderWidth"]) { // 属于占位span NSAttributedString *placeholderSpanAttributedString = [self p_createPlaceholderSpanAttributedStringWithSpan:span]; [richAttrArray addObject:placeholderSpanAttributedString]; @@ -568,8 +719,6 @@ - (NSMutableAttributedString *)p_buildAttributedString { CGFloat headIndent = [KRConvertUtil CGFloat:propStyle[@"headIndent"]]; UIColor *strokeColor = [UIView css_color:propStyle[@"strokeColor"]]; CGFloat strokeWidth = [KRConvertUtil CGFloat:propStyle[@"strokeWidth"]]; - NSInteger spanIndex = [spans indexOfObject:span]; - NSShadow *textShadow = nil; NSString *cssTextShadow = propStyle[@"textShadow"]; if ([cssTextShadow isKindOfClass:[NSString class]] && cssTextShadow.length > 0) { @@ -627,6 +776,28 @@ - (NSMutableAttributedString *)p_buildAttributedString { } else if (propStyle[@"slockInlineCode"]) { spanAttrs.slockChrome = @"inlineCode"; } + BOOL hasInlineBoxStyle = propStyle[@"inlineBoxBackgroundColor"] || + propStyle[@"inlineBoxBorderColor"] || propStyle[@"inlineBoxBorderWidth"] || + propStyle[@"inlineBoxPaddingStart"] || propStyle[@"inlineBoxPaddingEnd"] || + propStyle[@"inlineBoxPaddingTop"] || propStyle[@"inlineBoxPaddingBottom"] || + propStyle[@"inlineBoxMarginStart"] || propStyle[@"inlineBoxMarginEnd"] || + propStyle[@"inlineBoxCornerRadius"]; + if (hasInlineBoxStyle) { + NSMutableDictionary *box = [NSMutableDictionary new]; + UIColor *boxBackground = [UIView css_color:propStyle[@"inlineBoxBackgroundColor"]]; + UIColor *boxBorder = [UIView css_color:propStyle[@"inlineBoxBorderColor"]]; + if (boxBackground) box[@"backgroundColor"] = boxBackground; + if (boxBorder) box[@"borderColor"] = boxBorder; + box[@"borderWidth"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxBorderWidth"]]); + box[@"paddingStart"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxPaddingStart"]]); + box[@"paddingEnd"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxPaddingEnd"]]); + box[@"paddingTop"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxPaddingTop"]]); + box[@"paddingBottom"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxPaddingBottom"]]); + box[@"marginStart"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxMarginStart"]]); + box[@"marginEnd"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxMarginEnd"]]); + box[@"cornerRadius"] = @([KRConvertUtil CGFloat:propStyle[@"inlineBoxCornerRadius"]]); + spanAttrs.inlineBoxStyle = box; + } // 组合属性,生成这段Span对应的富文本 NSMutableAttributedString *spanAttrString = [self p_createSpanAttributedStringWithAttributes:spanAttrs]; if (spanAttrString) { @@ -654,6 +825,143 @@ - (NSMutableAttributedString *)p_buildAttributedString { return resAttr; } +- (NSMutableDictionary *)p_inlineBoxStyleFromSpan:(NSDictionary *)span { + NSMutableDictionary *box = [NSMutableDictionary new]; + UIColor *background = [UIView css_color:span[@"inlineBoxBackgroundColor"]]; + UIColor *border = [UIView css_color:span[@"inlineBoxBorderColor"]]; + if (background) box[@"backgroundColor"] = background; + if (border) box[@"borderColor"] = border; + box[@"borderWidth"] = @([KRConvertUtil CGFloat:span[@"inlineBoxBorderWidth"]]); + box[@"paddingStart"] = @([KRConvertUtil CGFloat:span[@"inlineBoxPaddingStart"]]); + box[@"paddingEnd"] = @([KRConvertUtil CGFloat:span[@"inlineBoxPaddingEnd"]]); + box[@"paddingTop"] = @([KRConvertUtil CGFloat:span[@"inlineBoxPaddingTop"]]); + box[@"paddingBottom"] = @([KRConvertUtil CGFloat:span[@"inlineBoxPaddingBottom"]]); + box[@"marginStart"] = @([KRConvertUtil CGFloat:span[@"inlineBoxMarginStart"]]); + box[@"marginEnd"] = @([KRConvertUtil CGFloat:span[@"inlineBoxMarginEnd"]]); + box[@"cornerRadius"] = @([KRConvertUtil CGFloat:span[@"inlineBoxCornerRadius"]]); + return box; +} + +- (NSString *)p_inlineBoxLayoutText:(NSString *)text { + if (text.length < 2) return text; + NSMutableString *joined = [NSMutableString string]; + __block BOOL first = YES; + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { + if (!first) [joined appendString:@"\u2060"]; + [joined appendString:substring]; + first = NO; + }]; + return joined; +} + +- (NSMutableAttributedString *)p_createInlineBoxGroupAttributedStringWithSpan:(NSMutableDictionary *)span + spanIndex:(NSInteger)spanIndex { + NSArray *children = span[@"inlineBoxChildren"]; + if (children.count == 0) return [NSMutableAttributedString new]; + NSMutableDictionary *style = [self p_inlineBoxStyleFromSpan:span]; + NSMutableDictionary *base = [(_props ?: @{}) mutableCopy]; + UIFont *baseFont = [KRConvertUtil UIFont:base] ?: [UIFont systemFontOfSize:15.0]; + CGFloat maxContentHeight = baseFont.lineHeight; + for (NSDictionary *child in children) { + if (child[@"placeholderHeight"]) { + maxContentHeight = MAX(maxContentHeight, [KRConvertUtil CGFloat:child[@"placeholderHeight"]]); + continue; + } + NSMutableDictionary *childStyle = [base mutableCopy]; + [childStyle addEntriesFromDictionary:child]; + UIFont *childFont = [KRConvertUtil UIFont:childStyle]; + if (childFont.lineHeight > maxContentHeight) { + maxContentHeight = childFont.lineHeight; + baseFont = childFont; + } + } + CGFloat borderWidth = [style[@"borderWidth"] doubleValue]; + CGFloat leadingAdvance = [style[@"marginStart"] doubleValue] + borderWidth + [style[@"paddingStart"] doubleValue]; + CGFloat trailingAdvance = [style[@"paddingEnd"] doubleValue] + borderWidth + [style[@"marginEnd"] doubleValue]; + CGFloat paddingTop = [style[@"paddingTop"] doubleValue]; + CGFloat paddingBottom = [style[@"paddingBottom"] doubleValue]; + style[@"boxHeight"] = @(maxContentHeight + paddingTop + paddingBottom + borderWidth * 2.0); + + NSMutableAttributedString *group = [NSMutableAttributedString new]; + KRInlineBoxEdgeAttachment *leading = [[KRInlineBoxEdgeAttachment alloc] + initWithAdvance:leadingAdvance + font:baseFont + paddingTop:paddingTop + paddingBottom:paddingBottom + borderWidth:borderWidth]; + [group appendAttributedString:[NSAttributedString attributedStringWithAttachment:leading]]; + + for (NSUInteger childIndex = 0; childIndex < children.count; childIndex++) { + NSMutableDictionary *child = children[childIndex]; + [group appendAttributedString:[[NSAttributedString alloc] initWithString:@"\u2060"]]; + if (child[@"placeholderWidth"]) { + [group appendAttributedString:[self p_createPlaceholderSpanAttributedStringWithSpan:child]]; + continue; + } + NSString *text = child[@"value"] ?: child[@"text"]; + if (text.length == 0) continue; + NSMutableDictionary *propStyle = [base mutableCopy]; + [propStyle addEntriesFromDictionary:child]; + KRSpanAttributes *attrs = [KRSpanAttributes new]; + // Treat an explicit inline-box group as one native word when it fits. TextKit + // otherwise considers punctuation such as '-' a preferred break point and + // fragments a group even though the complete group fits on the next line. + // U+2060 is layout-only: group semantic text remains authoritative for + // selection/copy/accessibility and KRLabel strips the glue on restoration. + attrs.text = [self p_inlineBoxLayoutText:text]; + attrs.spanIndex = spanIndex; + attrs.font = [KRConvertUtil UIFont:propStyle]; + attrs.color = [UIView css_color:propStyle[@"color"]] ?: [UIColor blackColor]; + attrs.backgroundColor = [UIView css_color:child[@"backgroundColor"]]; + NSString *cssGradient = propStyle[@"backgroundImage"]; + attrs.hasGradient = [cssGradient isKindOfClass:[NSString class]] && [cssGradient hasPrefix:@"linear-gradient("]; + attrs.cssGradient = cssGradient; + attrs.letterSpacing = [KRConvertUtil CGFloat:propStyle[@"letterSpacing"]]; + attrs.textDecoration = [KRConvertUtil KRTextDecorationLineType:propStyle[@"textDecoration"]]; + attrs.textDecorationColor = [UIView css_color:propStyle[@"textDecorationColor"]]; + attrs.textDecorationThickness = propStyle[@"textDecorationThickness"] ? @([KRConvertUtil CGFloat:propStyle[@"textDecorationThickness"]]) : nil; + attrs.textDecorationOffset = propStyle[@"textDecorationOffset"] ? @([KRConvertUtil CGFloat:propStyle[@"textDecorationOffset"]]) : nil; + attrs.textAlign = [KRConvertUtil NSTextAlignment:propStyle[@"textAlign"]]; + attrs.lineHeight = propStyle[@"lineHeight"] ? @([KRConvertUtil CGFloat:propStyle[@"lineHeight"]]) : nil; + attrs.lineSpacing = attrs.lineHeight ? nil : @([KRConvertUtil CGFloat:propStyle[@"lineSpacing"]]); + attrs.paragraphSpacing = propStyle[@"paragraphSpacing"] ? @([KRConvertUtil CGFloat:propStyle[@"paragraphSpacing"]]) : nil; + attrs.headIndent = [KRConvertUtil CGFloat:propStyle[@"headIndent"]]; + attrs.strokeColor = [UIView css_color:propStyle[@"strokeColor"]]; + attrs.strokeWidth = [KRConvertUtil CGFloat:propStyle[@"strokeWidth"]]; + NSString *cssTextShadow = propStyle[@"textShadow"]; + if ([cssTextShadow isKindOfClass:[NSString class]] && cssTextShadow.length > 0) { + CSSBoxShadow *shadow = [[CSSBoxShadow alloc] initWithCSSBoxShadow:cssTextShadow]; + NSShadow *textShadow = [NSShadow new]; + textShadow.shadowColor = shadow.shadowColor; + textShadow.shadowOffset = CGSizeMake(shadow.offsetX, shadow.offsetY); + textShadow.shadowBlurRadius = shadow.shadowRadius; + attrs.shadow = textShadow; + } + attrs.richAttrArray = @[]; + NSMutableAttributedString *childString = [self p_createSpanAttributedStringWithAttributes:attrs]; + if (childString.length > 0) [group appendAttributedString:childString]; + } + [group appendAttributedString:[[NSAttributedString alloc] initWithString:@"\u2060"]]; + KRInlineBoxEdgeAttachment *trailing = [[KRInlineBoxEdgeAttachment alloc] + initWithAdvance:trailingAdvance + font:baseFont + paddingTop:paddingTop + paddingBottom:paddingBottom + borderWidth:borderWidth]; + [group appendAttributedString:[NSAttributedString attributedStringWithAttachment:trailing]]; + + NSRange range = NSMakeRange(0, group.length); + [group addAttribute:KRInlineBoxStyleAttributeName value:style range:range]; + NSString *semantic = span[@"inlineBoxSemanticText"]; + if ([semantic isKindOfClass:[NSString class]] && semantic.length > 0) { + [group addAttribute:KRInlineBoxSemanticAttributeName value:semantic range:range]; + } + [group addAttribute:KuiklyIndexAttributeName value:@(spanIndex) range:range]; + return group; +} + // task #439 ⑥: reserve the chip's inline-box advance (px-1 padding + 1px border) in // LAYOUT via kern, so neighbors are pushed outside the box like React's inline-block // (border→neighbor keeps a ~1-space gap) instead of laying out into the painted @@ -761,6 +1069,32 @@ - (nullable NSMutableAttributedString *)p_createSlockInlineCodeAtomChainWithAttr - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttributes:(KRSpanAttributes *)attrs { + if (attrs.inlineBoxStyle && attrs.text.length > 0) { + KRInlineBoxAttachment *attachment = [[KRInlineBoxAttachment alloc] + initWithText:attrs.text + font:attrs.font + textColor:attrs.color + style:attrs.inlineBoxStyle + letterSpacing:attrs.letterSpacing]; + NSMutableAttributedString *atomicBox = [[NSMutableAttributedString alloc] + initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; + NSRange atomicRange = NSMakeRange(0, atomicBox.length); + [atomicBox addAttribute:NSWritingDirectionAttributeName + value:@[@((NSInteger)NSWritingDirectionLeftToRight | (NSInteger)NSWritingDirectionOverride)] + range:atomicRange]; + [atomicBox addAttribute:NSFontAttributeName value:attrs.font ?: [UIFont systemFontOfSize:15.0] range:atomicRange]; + [atomicBox addAttribute:KuiklyIndexAttributeName value:@(attrs.spanIndex) range:atomicRange]; + [self p_applyTextAttributeWithAttr:atomicBox + textAliment:attrs.textAlign + lineSpacing:attrs.lineSpacing + paragraphSpacing:attrs.paragraphSpacing + lineHeight:attrs.lineHeight + range:atomicRange + fontSize:attrs.font.pointSize + headIndent:attrs.headIndent + font:attrs.font ?: [UIFont systemFontOfSize:15.0]]; + return atomicBox; + } if ([attrs.slockChrome isEqualToString:@"inlineCode"] && attrs.text.length > 0) { return [self p_createSlockInlineCodeAtomChainWithAttributes:attrs]; } @@ -961,9 +1295,20 @@ - (NSString *)css_spanRectWithParams:(NSString *)params { if (!_mAttributedString) { // 文本还未排版,调用无效 return @""; } - NSInteger spanIndex = [params intValue]; + NSArray *path = [params componentsSeparatedByString:@" "]; + NSInteger spanIndex = [path.firstObject integerValue]; if (spanIndex < _spans.count ) { - KRRichTextAttachment *attachment = _spans[spanIndex][@"attachment"]; + NSDictionary *span = _spans[spanIndex]; + NSDictionary *attachmentOwner = span; + if (path.count > 1 && [span[@"inlineBoxChildren"] isKindOfClass:[NSArray class]]) { + NSInteger childIndex = [path[1] integerValue]; + NSArray *children = span[@"inlineBoxChildren"]; + if (childIndex >= 0 && childIndex < children.count) { + attachmentOwner = children[childIndex]; + } + } + KRRichTextAttachment *attachment = attachmentOwner[@"attachment"]; + if (!attachment) return @""; // 检查attachment是否在可见范围内 NSInteger numberOfLines = [KRConvertUtil NSInteger:_props[@"numberOfLines"]]; diff --git a/core-render-ios/Extension/Vendor/KRLabel.h b/core-render-ios/Extension/Vendor/KRLabel.h index 40a5ed72f..ca6cc5514 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.h +++ b/core-render-ios/Extension/Vendor/KRLabel.h @@ -28,6 +28,8 @@ extern NSString *const KRBGAttributeKey; // on a span's range so KRLayoutManager can draw the bordered chip that a plain // text SpanStyle / NSBackgroundColorAttributeName cannot express. extern NSString *const KRSlockChromeAttributeName; +extern NSString *const KRInlineBoxStyleAttributeName; +extern NSString *const KRInlineBoxSemanticAttributeName; @protocol KRSlockInlineCodeAtomProtocol - (BOOL)kr_slockInlineCodeLeadingEdge; diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 7be23df39..9655e57c1 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -25,6 +25,8 @@ NSString *const KRHighlightAttributeKey = @"KRHighlightAttributeKey"; NSString *const KRBGAttributeKey = @"KRBGAttributeKey"; NSString *const KRSlockChromeAttributeName = @"KRSlockChromeAttributeName"; +NSString *const KRInlineBoxStyleAttributeName = @"KRInlineBoxStyleAttributeName"; +NSString *const KRInlineBoxSemanticAttributeName = @"KRInlineBoxSemanticAttributeName"; #pragma mark - Slock rich-text chip chrome (task #439) @@ -70,7 +72,7 @@ return [UIColor colorWithRed:r green:g blue:b alpha:a]; } -static NSString *KRRestoredTextAttachmentString(NSAttributedString *attributedString) { +static NSString *KRRestoredAttachmentString(NSAttributedString *attributedString) { if (attributedString.length == 0) { return @""; } @@ -97,6 +99,35 @@ return result; } +static NSString *KRRestoredTextAttachmentString(NSAttributedString *attributedString) { + if (attributedString.length == 0) { + return @""; + } + NSMutableString *result = [NSMutableString string]; + __block NSUInteger cursor = 0; + [attributedString enumerateAttribute:KRInlineBoxSemanticAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if (![value isKindOfClass:[NSString class]]) { + return; + } + if (range.location > cursor) { + NSAttributedString *prefix = [attributedString attributedSubstringFromRange:NSMakeRange(cursor, range.location - cursor)]; + [result appendString:KRRestoredAttachmentString(prefix)]; + } + [result appendString:(NSString *)value]; + cursor = NSMaxRange(range); + }]; + if (cursor < attributedString.length) { + NSAttributedString *suffix = [attributedString attributedSubstringFromRange:NSMakeRange(cursor, attributedString.length - cursor)]; + [result appendString:KRRestoredAttachmentString(suffix)]; + } + NSString *restored = result.length > 0 ? result : KRRestoredAttachmentString(attributedString); + return [[restored stringByReplacingOccurrencesOfString:@"\u2060" withString:@""] + stringByReplacingOccurrencesOfString:@"\uFFFC" withString:@""]; +} + @interface KRLabel() @@ -596,6 +627,7 @@ @implementation KRLayoutManager{ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { _drawAtPoint = origin; [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + [self kr_drawInlineBoxChromeForGlyphRange:glyphsToShow atPoint:origin]; // Slock chip chrome (task #439). Drawn in drawBackground (before glyphs) so the // fill sits behind the text; the border is inset from the glyphs by the leading/ // trailing NBSP padding reserved on the shared side, so it never overlaps glyphs. @@ -603,6 +635,75 @@ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origi _drawAtPoint = CGPointZero; } +- (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { + NSTextStorage *textStorage = self.textStorage; + NSTextContainer *container = self.textContainers.firstObject; + CGContextRef ctx = UIGraphicsGetCurrentContext(); + if (textStorage.length == 0 || !container || !ctx) { + return; + } + NSRange charRange = [self characterRangeForGlyphRange:glyphsToShow actualGlyphRange:NULL]; + [textStorage enumerateAttribute:KRInlineBoxStyleAttributeName + inRange:charRange + options:0 + usingBlock:^(id value, NSRange runRange, BOOL *stop) { + if (![value isKindOfClass:[NSDictionary class]]) return; + NSDictionary *style = (NSDictionary *)value; + NSRange runGlyphRange = [self glyphRangeForCharacterRange:runRange actualCharacterRange:NULL]; + if (runGlyphRange.length == 0) return; + [self enumerateLineFragmentsForGlyphRange:runGlyphRange + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *lineContainer, NSRange lineGlyphRange, BOOL *lineStop) { + NSRange segment = NSIntersectionRange(lineGlyphRange, runGlyphRange); + if (segment.length == 0) return; + CGRect bounds = [self boundingRectForGlyphRange:segment inTextContainer:lineContainer]; + CGFloat borderWidth = [style[@"borderWidth"] doubleValue]; + CGFloat paddingTop = [style[@"paddingTop"] doubleValue]; + CGFloat paddingBottom = [style[@"paddingBottom"] doubleValue]; + // The edge attachments already reserve margin in TextKit's layout + // advance. Keep that advance inside the painted group fragment; + // trimming it here leaves transparent white notches immediately + // before and after an otherwise continuous bordered inline box. + CGFloat left = CGRectGetMinX(bounds) + origin.x; + CGFloat right = CGRectGetMaxX(bounds) + origin.x; + if (right <= left) return; + CGFloat boxHeight = [style[@"boxHeight"] doubleValue]; + if (boxHeight <= 0) { + boxHeight = CGRectGetHeight(bounds) + paddingTop + paddingBottom + borderWidth * 2.0; + } + CGFloat centerY = CGRectGetMidY(bounds) + origin.y; + CGFloat top = centerY - boxHeight / 2.0; + CGFloat bottom = centerY + boxHeight / 2.0; + CGRect rect = CGRectMake(left, top, right - left, bottom - top); + UIColor *fill = style[@"backgroundColor"]; + UIColor *border = style[@"borderColor"]; + CGFloat radius = [style[@"cornerRadius"] doubleValue]; + if ([fill isKindOfClass:[UIColor class]]) { + CGContextSetFillColorWithColor(ctx, fill.CGColor); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius]; + [path fill]; + } + if ([border isKindOfClass:[UIColor class]] && borderWidth > 0) { + CGContextSetStrokeColorWithColor(ctx, border.CGColor); + CGContextSetLineWidth(ctx, borderWidth); + CGRect strokeRect = CGRectInset(rect, borderWidth / 2.0, borderWidth / 2.0); + // TextKit clips background drawing to the current line fragment. + // When a whole group is pushed onto the next visual line, the + // nominal bottom edge can land exactly on that clip boundary and + // disappear. Keep the stroke center inside the drawable fragment; + // layout metrics and the fill rect remain unchanged. + CGFloat fragmentTop = CGRectGetMinY(lineRect) + origin.y; + CGFloat fragmentBottom = CGRectGetMaxY(lineRect) + origin.y; + CGFloat strokeTop = MAX(CGRectGetMinY(strokeRect), fragmentTop + borderWidth / 2.0); + CGFloat strokeBottom = MIN(CGRectGetMaxY(strokeRect), fragmentBottom - borderWidth / 2.0); + strokeRect.origin.y = strokeTop; + strokeRect.size.height = MAX(0, strokeBottom - strokeTop); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect cornerRadius:MAX(0, radius - borderWidth / 2.0)]; + [path stroke]; + } + }]; + }]; +} + // TEMPORARY BRIDGE TO TASK #442 — ports core-render-android KRRichTextViewDrawer.kt // drawSlockInlineCodeChrome/drawSlockMarkdownTagChrome geometry to TextKit. #442 moves // the resolved token values into span props so this reads prop data instead of the diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp index a411a086f..30902f110 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp @@ -71,12 +71,40 @@ namespace { constexpr char16_t kSlockNonBreakingSpace = u'\u00A0'; constexpr char16_t kSlockZeroWidthBreak = u'\u200B'; +constexpr char16_t kInlineBoxWordJoiner = u'\u2060'; constexpr char16_t kObjectReplacementCharacter = u'\uFFFC'; constexpr float kSlockInnerPaddingRatio = 4.0f / 15.0f; constexpr float kSlockOuterMarginRatio = 2.0f / 15.0f; +constexpr float kSlockChipBorderWidthVp = 1.0f; constexpr float kSlockTrailingMarginRatio = 1.0f / 15.0f; constexpr float kSlockChipLineHeightRatio = 1.5f; +constexpr char kInlineBoxGroupIndexKey[] = "__kr_inline_box_group_index__"; +constexpr char kTopLevelSpanIndexKey[] = "__kr_top_level_span_index__"; +constexpr char kInlineBoxChildIndexKey[] = "__kr_inline_box_child_index__"; +constexpr char kInlineBoxPartKey[] = "__kr_inline_box_part__"; +constexpr char kInlineBoxPartLeading[] = "leading"; +constexpr char kInlineBoxPartGlue[] = "glue"; +constexpr char kInlineBoxPartChild[] = "child"; +constexpr char kInlineBoxPartTrailing[] = "trailing"; + +struct KRInlineBoxGroupPlan { + int span_index = -1; + int layout_start = -1; + int layout_end = -1; + int semantic_start = -1; + std::u16string semantic_text; + uint32_t fill_color = 0; + uint32_t border_color = 0; + float border_width_px = 0; + float padding_start_px = 0; + float padding_end_px = 0; + float margin_start_px = 0; + float margin_end_px = 0; + float box_height_px = 0; + float corner_radius_px = 0; +}; + std::u16string KRUtf8ToUtf16(const std::string &text) { std::wstring_convert, char16_t> converter; return converter.from_bytes(text); @@ -183,7 +211,7 @@ void KRRichTextShadow::SetProp(const std::string &prop_key, const KRAnyValue &pr */ KRAnyValue KRRichTextShadow::Call(const std::string &method_name, const std::string ¶ms) { if (kuikly::util::isEqual(method_name, "spanRect")) { // 调用获取placeholder span位置方法 - return SpanRect(NewKRRenderValue(params)->toInt()); + return SpanRect(params); } else if(method_name == "isLineBreakMargin"){ return NewKRRenderValue(did_exceed_max_lines_ && OH_Drawing_DestroyTextLines? "1" : "0"); } @@ -525,6 +553,124 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w } } + // Preserve an explicit RichText inline-box group as native text runs. The + // group contributes only fixed edge placeholders and layout-only word + // joiners; child text remains ordinary typography text and is therefore + // measured and wrapped by OH_Drawing itself. + std::vector inline_box_group_plans; + { + KRRenderValue::Array flattened; + const double group_dpi = KRConfig::GetDpi(); + int top_level_index = 0; + auto erase_box_style = [](KRRenderValue::Map &map) { + map.erase("inlineBoxBackgroundColor"); + map.erase("inlineBoxBorderColor"); + map.erase("inlineBoxBorderWidth"); + map.erase("inlineBoxPaddingStart"); + map.erase("inlineBoxPaddingEnd"); + map.erase("inlineBoxPaddingTop"); + map.erase("inlineBoxPaddingBottom"); + map.erase("inlineBoxMarginStart"); + map.erase("inlineBoxMarginEnd"); + map.erase("inlineBoxCornerRadius"); + map.erase("inlineBoxChildren"); + map.erase("inlineBoxSemanticText"); + }; + for (const auto &span : spans) { + auto group_map = span->toMap(); + auto children = GetKRValue("inlineBoxChildren", group_map, group_map)->toArray(); + if (children.empty()) { + group_map[kTopLevelSpanIndexKey] = NewKRRenderValue(top_level_index++); + flattened.push_back(KRRenderValue::Make(group_map)); + continue; + } + + const float border_vp = GetKRValue("inlineBoxBorderWidth", group_map, group_map)->toFloat(); + const float padding_start_vp = GetKRValue("inlineBoxPaddingStart", group_map, group_map)->toFloat(); + const float padding_end_vp = GetKRValue("inlineBoxPaddingEnd", group_map, group_map)->toFloat(); + const float padding_top_vp = GetKRValue("inlineBoxPaddingTop", group_map, group_map)->toFloat(); + const float padding_bottom_vp = GetKRValue("inlineBoxPaddingBottom", group_map, group_map)->toFloat(); + const float margin_start_vp = GetKRValue("inlineBoxMarginStart", group_map, group_map)->toFloat(); + const float margin_end_vp = GetKRValue("inlineBoxMarginEnd", group_map, group_map)->toFloat(); + float content_height_vp = GetKRValue("fontSize", group_map, props_)->toFloat(); + if (content_height_vp <= 0) content_height_vp = 15.0f; + for (const auto &child : children) { + const auto child_map = child->toMap(); + const float child_font = GetKRValue("fontSize", child_map, props_)->toFloat(); + const float child_placeholder = GetKRValue("placeholderHeight", child_map, child_map)->toFloat(); + content_height_vp = std::max(content_height_vp, std::max(child_font, child_placeholder)); + } + const float box_height_vp = content_height_vp + padding_top_vp + padding_bottom_vp + border_vp * 2.0f; + + KRInlineBoxGroupPlan plan; + plan.span_index = top_level_index; + plan.semantic_text = KRUtf8ToUtf16( + GetKRValue("inlineBoxSemanticText", group_map, group_map)->toString()); + const std::string fill = GetKRValue("inlineBoxBackgroundColor", group_map, group_map)->toString(); + const std::string border = GetKRValue("inlineBoxBorderColor", group_map, group_map)->toString(); + plan.fill_color = fill.empty() ? 0 : kuikly::util::ConvertToHexColor(fill); + plan.border_color = border.empty() ? 0 : kuikly::util::ConvertToHexColor(border); + plan.border_width_px = border_vp * group_dpi; + plan.padding_start_px = padding_start_vp * group_dpi; + plan.padding_end_px = padding_end_vp * group_dpi; + plan.margin_start_px = margin_start_vp * group_dpi; + plan.margin_end_px = margin_end_vp * group_dpi; + plan.box_height_px = box_height_vp * group_dpi; + plan.corner_radius_px = + GetKRValue("inlineBoxCornerRadius", group_map, group_map)->toFloat() * group_dpi; + inline_box_group_plans.push_back(plan); + + auto make_part = [&](const char *part) { + auto map = group_map; + erase_box_style(map); + map[kTopLevelSpanIndexKey] = NewKRRenderValue(top_level_index); + map[kInlineBoxGroupIndexKey] = NewKRRenderValue(top_level_index); + map[kInlineBoxPartKey] = NewKRRenderValue(std::string(part)); + return map; + }; + + auto leading = make_part(kInlineBoxPartLeading); + leading["value"] = NewKRRenderValue(std::string("")); + leading["text"] = NewKRRenderValue(std::string("")); + leading["placeholderWidth"] = NewKRRenderValue( + static_cast(margin_start_vp + border_vp + padding_start_vp)); + leading["placeholderHeight"] = NewKRRenderValue(static_cast(box_height_vp)); + flattened.push_back(KRRenderValue::Make(leading)); + + int child_index = 0; + for (const auto &child : children) { + auto glue = make_part(kInlineBoxPartGlue); + glue["value"] = NewKRRenderValue(KRUtf16ToUtf8(std::u16string(1, kInlineBoxWordJoiner))); + glue["text"] = glue["value"]; + flattened.push_back(KRRenderValue::Make(glue)); + + auto child_map = child->toMap(); + erase_box_style(child_map); + child_map[kTopLevelSpanIndexKey] = NewKRRenderValue(top_level_index); + child_map[kInlineBoxGroupIndexKey] = NewKRRenderValue(top_level_index); + child_map[kInlineBoxChildIndexKey] = NewKRRenderValue(child_index++); + child_map[kInlineBoxPartKey] = NewKRRenderValue(std::string(kInlineBoxPartChild)); + flattened.push_back(KRRenderValue::Make(child_map)); + } + + auto trailing_glue = make_part(kInlineBoxPartGlue); + trailing_glue["value"] = NewKRRenderValue(KRUtf16ToUtf8(std::u16string(1, kInlineBoxWordJoiner))); + trailing_glue["text"] = trailing_glue["value"]; + flattened.push_back(KRRenderValue::Make(trailing_glue)); + + auto trailing = make_part(kInlineBoxPartTrailing); + trailing["value"] = NewKRRenderValue(std::string("")); + trailing["text"] = NewKRRenderValue(std::string("")); + trailing["placeholderWidth"] = NewKRRenderValue( + static_cast(padding_end_vp + border_vp + margin_end_vp)); + trailing["placeholderHeight"] = NewKRRenderValue(static_cast(box_height_vp)); + flattened.push_back(KRRenderValue::Make(trailing)); + + ++top_level_index; + } + spans = std::move(flattened); + } + auto numberOfLines = GetKRValue("numberOfLines", props_, props_)->toInt(); const std::string lineBreakModeStr = GetKRValue("lineBreakMode", props_, props_)->toString(); auto lineBreakMode = kuikly::util::ConvertToTextBreakMode(lineBreakModeStr); @@ -536,7 +682,6 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_TypographyStyle *typoStyle = nullptr; OH_Drawing_TypographyCreate *handler = nullptr; bool isFirst = true; - int spanIndex = 0; int placeholder_count = 0; OH_Drawing_TextAlign text_align = TEXT_ALIGN_LEFT; int charOffset = 0; @@ -565,6 +710,26 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w }; for (auto span : spans) { auto spanMap = span->toMap(); + const int spanIndex = GetKRValue(kTopLevelSpanIndexKey, spanMap, spanMap)->toInt(); + const int inlineBoxGroupIndex = GetKRValue(kInlineBoxGroupIndexKey, spanMap, spanMap)->toInt(); + const int inlineBoxChildIndex = GetKRValue(kInlineBoxChildIndexKey, spanMap, spanMap)->toInt(); + const std::string inlineBoxPart = GetKRValue(kInlineBoxPartKey, spanMap, spanMap)->toString(); + const bool isInlineBoxGroupPart = !inlineBoxPart.empty(); + KRInlineBoxGroupPlan *inlineBoxGroupPlan = nullptr; + if (isInlineBoxGroupPart) { + auto plan_it = std::find_if( + inline_box_group_plans.begin(), inline_box_group_plans.end(), + [inlineBoxGroupIndex](const KRInlineBoxGroupPlan &plan) { + return plan.span_index == inlineBoxGroupIndex; + }); + if (plan_it != inline_box_group_plans.end()) { + inlineBoxGroupPlan = &(*plan_it); + if (inlineBoxPart == kInlineBoxPartLeading && inlineBoxGroupPlan->layout_start < 0) { + inlineBoxGroupPlan->layout_start = charOffset; + inlineBoxGroupPlan->semantic_start = static_cast(semantic_text_content.size()); + } + } + } auto fontSize = (GetKRValue("fontSize", spanMap, props_)->toFloat() ?: 15.0) * dpi * fontSizeScale; auto text = GetKRValue("value", spanMap, spanMap)->toString(); if (text.length() == 0) { @@ -604,7 +769,33 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w const std::string slockChromeKind = slockInlineCode ? "inlineCode" : slockTagChrome; const uint32_t slockFillColor = KRSlockChromeFillColor(slockChromeKind); const bool isSlockChip = slockFillColor != 0; - if (isSlockChip) { + const std::string inlineBoxBackgroundColorStr = + GetKRValue("inlineBoxBackgroundColor", spanMap, spanMap)->toString(); + const std::string inlineBoxBorderColorStr = + GetKRValue("inlineBoxBorderColor", spanMap, spanMap)->toString(); + const float inlineBoxBorderWidth = + GetKRValue("inlineBoxBorderWidth", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxPaddingStart = + GetKRValue("inlineBoxPaddingStart", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxPaddingEnd = + GetKRValue("inlineBoxPaddingEnd", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxPaddingTop = + GetKRValue("inlineBoxPaddingTop", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxPaddingBottom = + GetKRValue("inlineBoxPaddingBottom", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxMarginStart = + GetKRValue("inlineBoxMarginStart", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxMarginEnd = + GetKRValue("inlineBoxMarginEnd", spanMap, spanMap)->toFloat() * dpi; + const float inlineBoxCornerRadius = + GetKRValue("inlineBoxCornerRadius", spanMap, spanMap)->toFloat() * dpi; + const bool isInlineBox = !isInlineBoxGroupPart && (inlineBoxBackgroundColorStr.length() || + inlineBoxBorderColorStr.length() || inlineBoxBorderWidth > 0 || + inlineBoxPaddingStart > 0 || inlineBoxPaddingEnd > 0 || + inlineBoxPaddingTop > 0 || inlineBoxPaddingBottom > 0 || + inlineBoxMarginStart > 0 || inlineBoxMarginEnd > 0 || inlineBoxCornerRadius > 0); + const bool hasBoxChrome = isSlockChip || isInlineBox; + if (hasBoxChrome) { textDecoration = TEXT_DECORATION_NONE; } @@ -616,7 +807,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_Brush *textBackgroundBrush = nullptr; // 设置文字大小、字重等属性设置到文本样式对象中 OH_Drawing_SetTextStyleColor(txtStyle, color); - if (!isSlockChip && backgroundColorStr.length() && backgroundColor != 0x00000000) { + if (!hasBoxChrome && backgroundColorStr.length() && backgroundColor != 0x00000000) { textBackgroundBrush = OH_Drawing_BrushCreate(); OH_Drawing_BrushSetColor(textBackgroundBrush, backgroundColor); OH_Drawing_SetTextStyleBackgroundBrush(txtStyle, textBackgroundBrush); @@ -776,7 +967,12 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w TEXT_BASELINE_ALPHABETIC, 0, }; OH_Drawing_TypographyHandlerAddPlaceholder(handler, &inlineView); - placeholder_index_map_[spanIndex] = placeholder_count; + if (!isInlineBoxGroupPart) { + placeholder_index_map_[std::to_string(spanIndex)] = placeholder_count; + } else if (inlineBoxPart == kInlineBoxPartChild) { + placeholder_index_map_[std::to_string(spanIndex) + " " + std::to_string(inlineBoxChildIndex)] = + placeholder_count; + } // 仅当此 placeholder 是由 PostProcessor("richtext") 展开产生的内置 image span // 时,登记到 image_draw_records_ 以便 view 层在 OnForegroundDraw 中绘制图片。 // 业务自己声明的 ImageSpan(无 kInternalImageSrcKey 字段)继续走"父节点 ImageView" @@ -792,7 +988,29 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w } placeholder_count++; charOffset += 1; - append_placeholder_mapping({}); + if (inlineBoxPart == kInlineBoxPartTrailing && inlineBoxGroupPlan) { + append_placeholder_mapping(inlineBoxGroupPlan->semantic_text); + inlineBoxGroupPlan->layout_end = charOffset; + span_offsets_.emplace_back( + std::tuple(spanIndex, inlineBoxGroupPlan->layout_start, inlineBoxGroupPlan->layout_end)); + context_thread_slock_chrome_runs_.push_back( + KRSlockChromeRun{ + inlineBoxGroupPlan->layout_start, + inlineBoxGroupPlan->layout_end, + inlineBoxGroupPlan->fill_color, + inlineBoxGroupPlan->border_color, + inlineBoxGroupPlan->border_width_px, + inlineBoxGroupPlan->padding_start_px, + inlineBoxGroupPlan->padding_end_px, + inlineBoxGroupPlan->margin_start_px, + inlineBoxGroupPlan->margin_end_px, + inlineBoxGroupPlan->box_height_px, + inlineBoxGroupPlan->corner_radius_px, + true, + }); + } else { + append_placeholder_mapping({}); + } } else if (slockInlineCodeTrailingMargin) { // Android's KRSlockInlineCodeTrailingMarginSpan contract: the source // space remains semantic text, while layout uses a 1/15 transparent @@ -810,18 +1028,41 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w charOffset += 1; append_placeholder_mapping(KRUtf8ToUtf16(text)); span_offsets_.emplace_back(std::tuple(spanIndex, spanStart, charOffset)); - } else if (isSlockChip) { - const float edgeAdvance = - fontSize * (kSlockInnerPaddingRatio + kSlockOuterMarginRatio); - OH_Drawing_PlaceholderSpan edgePlaceholder = { - edgeAdvance, - fontSize * kSlockChipLineHeightRatio, + } else if (hasBoxChrome) { + const float borderWidth = isInlineBox + ? inlineBoxBorderWidth + : std::max(1.0f, static_cast(dpi) * kSlockChipBorderWidthVp); + const float paddingStart = isInlineBox + ? inlineBoxPaddingStart + : fontSize * kSlockInnerPaddingRatio; + const float paddingEnd = isInlineBox + ? inlineBoxPaddingEnd + : fontSize * kSlockInnerPaddingRatio; + const float marginStart = isInlineBox + ? inlineBoxMarginStart + : fontSize * kSlockOuterMarginRatio; + const float marginEnd = isInlineBox + ? inlineBoxMarginEnd + : fontSize * kSlockOuterMarginRatio; + const float boxHeight = isInlineBox + ? fontSize + inlineBoxPaddingTop + inlineBoxPaddingBottom + borderWidth * 2.0f + : fontSize * kSlockChipLineHeightRatio; + OH_Drawing_PlaceholderSpan leadingEdgePlaceholder = { + marginStart + borderWidth + paddingStart, + boxHeight, + ALIGNMENT_CENTER_OF_ROW_BOX, + TEXT_BASELINE_ALPHABETIC, + 0, + }; + OH_Drawing_PlaceholderSpan trailingEdgePlaceholder = { + paddingEnd + borderWidth + marginEnd, + boxHeight, ALIGNMENT_CENTER_OF_ROW_BOX, TEXT_BASELINE_ALPHABETIC, 0, }; const int spanStart = charOffset; - OH_Drawing_TypographyHandlerAddPlaceholder(handler, &edgePlaceholder); + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &leadingEdgePlaceholder); placeholder_count++; charOffset += 1; append_placeholder_mapping({}); @@ -847,10 +1088,31 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w const int chromeEnd = charOffset; if (chromeEnd > chromeStart) { context_thread_slock_chrome_runs_.push_back( - KRSlockChromeRun{chromeStart, chromeEnd, slockFillColor, static_cast(fontSize)}); + KRSlockChromeRun{ + chromeStart, + chromeEnd, + isInlineBox + ? (inlineBoxBackgroundColorStr.length() + ? kuikly::util::ConvertToHexColor(inlineBoxBackgroundColorStr) + : 0) + : slockFillColor, + isInlineBox + ? (inlineBoxBorderColorStr.length() + ? kuikly::util::ConvertToHexColor(inlineBoxBorderColorStr) + : 0) + : 0xFF000000, + borderWidth, + paddingStart, + paddingEnd, + marginStart, + marginEnd, + boxHeight, + inlineBoxCornerRadius, + false, + }); } - OH_Drawing_TypographyHandlerAddPlaceholder(handler, &edgePlaceholder); + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &trailingEdgePlaceholder); placeholder_count++; charOffset += 1; append_placeholder_mapping({}); @@ -859,9 +1121,15 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_TypographyHandlerAddText(handler, text.c_str()); // 添加文本 const std::u16string text16 = KRUtf8ToUtf16(text); const int codePointCount = static_cast(text16.size()); - span_offsets_.emplace_back(std::tuple(spanIndex, charOffset, charOffset + codePointCount)); + if (!isInlineBoxGroupPart) { + span_offsets_.emplace_back(std::tuple(spanIndex, charOffset, charOffset + codePointCount)); + } charOffset += codePointCount; - append_mapped_text(text16, text16, nullptr); + if (isInlineBoxGroupPart) { + append_mapped_text(text16, {}, nullptr); + } else { + append_mapped_text(text16, text16, nullptr); + } } OH_Drawing_DestroyTextStyle(txtStyle); if (textForegroundPen) { @@ -876,7 +1144,6 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_BrushDestroy(textBackgroundBrush); textBackgroundBrush = nullptr; } - spanIndex++; } // 根据handler对象生成文本排版布局typography context_thread_typography_ = KRMakeTypographyHandle(OH_Drawing_CreateTypography(handler)); @@ -979,7 +1246,8 @@ void KRRichTextShadow::TriggerImagePrefetchIfNeed() { /** * 调用获取Span位置方法 */ -KRAnyValue KRRichTextShadow::SpanRect(int spanIndex) { +KRAnyValue KRRichTextShadow::SpanRect(const std::string &spanPath) { + const int spanIndex = NewKRRenderValue(spanPath)->toInt(); if(auto paragraph = GetParagraph()){ auto [paragraphX, paragraphY, paragraphW, paragraphH] = paragraph->SpanRect(spanIndex); char buffer[50] = {0}; @@ -988,8 +1256,8 @@ KRAnyValue KRRichTextShadow::SpanRect(int spanIndex) { return NewKRRenderValue(buffer); } - if (placeholder_index_map_.find(spanIndex) != placeholder_index_map_.end()) { - auto placeholderIndex = placeholder_index_map_[spanIndex]; + if (placeholder_index_map_.find(spanPath) != placeholder_index_map_.end()) { + auto placeholderIndex = placeholder_index_map_[spanPath]; // 在调用栈内拷贝一份强引用,避免其它线程同时 ReleaseLastTypography 释放。 KRTypographyHandle typo = context_thread_typography_; OH_Drawing_Typography *typo_raw = typo ? typo.get() : nullptr; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h index aeac85503..579ebb9b0 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.h @@ -67,7 +67,15 @@ struct KRSlockChromeRun { int start = 0; int end = 0; uint32_t fill_color = 0; - float font_size_px = 0; + uint32_t border_color = 0xFF000000; + float border_width_px = 0; + float padding_start_px = 0; + float padding_end_px = 0; + float margin_start_px = 0; + float margin_end_px = 0; + float box_height_px = 0; + float corner_radius_px = 0; + bool includes_reserved_edges = false; }; inline KRTypographyHandle KRMakeTypographyHandle(OH_Drawing_Typography *raw) { @@ -193,6 +201,10 @@ class KRRichTextShadow : public IKRRenderShadowExport { return main_thread_text_content_; } + std::string GetSemanticTextContent() const { + return main_thread_semantic_text_content_; + } + const std::vector &SlockChromeRuns() const { return main_thread_slock_chrome_runs_; } @@ -314,7 +326,7 @@ class KRRichTextShadow : public IKRRenderShadowExport { KRSize context_measure_size_; KRSize main_measure_size_; - std::unordered_map placeholder_index_map_; + std::unordered_map placeholder_index_map_; std::vector> span_offsets_; // span, begin, end std::shared_ptr paragraph_; KRSpinLock paragraph_lock_; @@ -343,7 +355,7 @@ class KRRichTextShadow : public IKRRenderShadowExport { /** * 调用获取Span位置方法 */ - KRAnyValue SpanRect(int spanIndex); + KRAnyValue SpanRect(const std::string &spanPath); int SpanIndexAt(float x, float y); int ResolveLongPressSpanIndex(const KRRenderValueMap ¶ms); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp index e6662460b..7348441cf 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include "libohos_render/expand/components/base/KRCustomUserCallback.h" @@ -35,6 +36,7 @@ #include "libohos_render/foundation/thread/KRMainThread.h" #include "libohos_render/foundation/KRPoint.h" #include "libohos_render/export/IKRRenderViewExport.h" +#include "libohos_render/utils/KRViewUtil.h" #ifdef __cplusplus extern "C" { @@ -57,10 +59,6 @@ extern size_t OH_Drawing_GetEndFromRange(OH_Drawing_Range* range) __attribute__( namespace { -constexpr float kSlockChipInnerPaddingRatio = 4.0f / 15.0f; -constexpr float kSlockChipLineHeightRatio = 1.5f; -constexpr float kSlockChipBorderWidthVp = 1.0f; - struct KRSlockChromeFragment { float left = 0; float top = 0; @@ -77,6 +75,18 @@ void KRDrawBrushRect(OH_Drawing_Canvas *canvas, float left, float top, float rig OH_Drawing_RectDestroy(rect); } +void KRDrawBrushRoundRect(OH_Drawing_Canvas *canvas, float left, float top, float right, float bottom, + float radius) { + if (!canvas || right <= left || bottom <= top) { + return; + } + OH_Drawing_Rect *rect = OH_Drawing_RectCreate(left, top, right, bottom); + OH_Drawing_RoundRect *roundRect = OH_Drawing_RoundRectCreate(rect, radius, radius); + OH_Drawing_CanvasDrawRoundRect(canvas, roundRect); + OH_Drawing_RoundRectDestroy(roundRect); + OH_Drawing_RectDestroy(rect); +} + std::vector KRCollectSlockChromeFragments(OH_Drawing_Typography *typography, const KRSlockChromeRun &run) { std::vector fragments; @@ -119,7 +129,6 @@ void KRDrawSlockChipChrome(OH_Drawing_Canvas *canvas, OH_Drawing_Typography *typ if (!canvas || !typography || runs.empty()) { return; } - const float density = KRConfig::GetDpi(); OH_Drawing_Brush *brush = OH_Drawing_BrushCreate(); OH_Drawing_BrushSetAntiAlias(brush, drawFill); @@ -131,22 +140,32 @@ void KRDrawSlockChipChrome(OH_Drawing_Canvas *canvas, OH_Drawing_Typography *typ // Native Drawing captures the brush state when it is attached to the // canvas. Set the per-run color first; mutating an already attached // brush leaves some HarmonyOS versions drawing the default black. - OH_Drawing_BrushSetColor(brush, drawFill ? run.fill_color : 0xFF000000); + OH_Drawing_BrushSetColor(brush, drawFill ? run.fill_color : run.border_color); OH_Drawing_CanvasAttachBrush(canvas, brush); - const float innerPadding = run.font_size_px * kSlockChipInnerPaddingRatio; - const float chipHeight = run.font_size_px * kSlockChipLineHeightRatio; - const float borderWidth = std::max(1.0f, density * kSlockChipBorderWidthVp); + const float chipHeight = run.box_height_px; + const float borderWidth = run.border_width_px; for (size_t i = 0; i < fragments.size(); ++i) { const auto &fragment = fragments[i]; const bool isSpanStart = i == 0; const bool isSpanEnd = i + 1 == fragments.size(); - const float left = fragment.left - (isSpanStart ? innerPadding : 0.0f); - const float right = fragment.right + (isSpanEnd ? innerPadding : 0.0f); + const float left = run.includes_reserved_edges + ? fragment.left + (isSpanStart ? run.margin_start_px : 0.0f) + : fragment.left - (isSpanStart ? run.padding_start_px + borderWidth : 0.0f); + const float right = run.includes_reserved_edges + ? fragment.right - (isSpanEnd ? run.margin_end_px : 0.0f) + : fragment.right + (isSpanEnd ? run.padding_end_px + borderWidth : 0.0f); const float centerY = (fragment.top + fragment.bottom) / 2.0f - drawOffsetY; const float top = centerY - chipHeight / 2.0f; const float bottom = centerY + chipHeight / 2.0f; - if (drawFill) { - KRDrawBrushRect(canvas, left, top, right, bottom); + if (drawFill && run.fill_color != 0) { + if (run.corner_radius_px > 0) { + KRDrawBrushRoundRect(canvas, left, top, right, bottom, run.corner_radius_px); + } else { + KRDrawBrushRect(canvas, left, top, right, bottom); + } + continue; + } + if (drawFill || borderWidth <= 0 || run.border_color == 0) { continue; } @@ -227,6 +246,9 @@ void KRRichTextView::SetShadow(const std::shared_ptr &sha shadow_ = shadow; auto textShadow = std::dynamic_pointer_cast(shadow); + if (textShadow && !has_explicit_accessibility_) { + kuikly::util::UpdateNodeAccessibility(GetNode(), textShadow->GetSemanticTextContent()); + } // 决策 6C:image span(由 PostProcessor("richtext") 拆段产生)只在 V1(老 typography) // OnForegroundDraw 路径下能被绘制——因为 V2 的 StyledString 是交给 ArkUI 节点直接 // 渲染,SDK 当前没暴露插入图片绘制 hook 的入口。这个判定已收敛到 @@ -286,6 +308,7 @@ void KRRichTextView::DidRemoveFromParentView() { shadow_ = nullptr; paragraph_ = nullptr; use_styled_string_ = false; + has_explicit_accessibility_ = false; last_draw_frame_width_ = -1.0; } @@ -507,6 +530,14 @@ void KRRichTextView::ToSetProp(const std::string &prop_key, const KRAnyValue &pr IKRRenderViewExport::ToSetProp(prop_key, prop_value, middleManCallback); } else if(prop_key == kPropNameLineBreakMargin) { line_break_margin_ = prop_value->toFloat(); + } else if (prop_key == "accessibility") { + has_explicit_accessibility_ = !prop_value->toString().empty(); + IKRRenderViewExport::ToSetProp(prop_key, prop_value, event_callback); + if (!has_explicit_accessibility_) { + if (auto richTextShadow = std::dynamic_pointer_cast(shadow_)) { + kuikly::util::UpdateNodeAccessibility(GetNode(), richTextShadow->GetSemanticTextContent()); + } + } }else { IKRRenderViewExport::ToSetProp(prop_key, prop_value, event_callback); } diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h index 9836f0b74..404ea0739 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextView.h @@ -157,7 +157,7 @@ class KRRichTextView : public IKRRenderViewExport { return selection_rects_.selection_rects.empty() ? KRRect() : selection_rects_.selection_rects.back(); } std::string GetTextContent() { - return std::dynamic_pointer_cast(shadow_)->GetTextContent(); + return std::dynamic_pointer_cast(shadow_)->GetSemanticTextContent(); } std::string GetSelectedContent(std::string &pre, std::string &post); bool IsTextView() override { @@ -167,6 +167,7 @@ class KRRichTextView : public IKRRenderViewExport { KRPoint ancestor_point2, int type) override; private: + bool has_explicit_accessibility_ = false; std::shared_ptr paragraph_; std::shared_ptr shadow_; bool use_styled_string_ = false; diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InlineBoxSpanStyle.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InlineBoxSpanStyle.kt new file mode 100644 index 000000000..ea5132f15 --- /dev/null +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InlineBoxSpanStyle.kt @@ -0,0 +1,39 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.views + +import com.tencent.kuikly.core.base.Color + +/** + * Semantic-agnostic inline box decoration carried by an existing [TextSpan]. + * + * Values use Kuikly logical pixels. Renderers must include the horizontal box + * geometry in text measurement, paint one fragment per final visual line, and + * keep hit-testing/copy mapped to the original span text. + */ +data class InlineBoxSpanStyle( + val backgroundColor: Color? = null, + val borderColor: Color? = null, + val borderWidth: Float = 0f, + val paddingStart: Float = 0f, + val paddingEnd: Float = 0f, + val paddingTop: Float = 0f, + val paddingBottom: Float = 0f, + val marginStart: Float = 0f, + val marginEnd: Float = 0f, + val cornerRadius: Float = 0f, +) + diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index 0421690c1..0258a2b9f 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -95,6 +95,20 @@ fun RichTextView.ImageSpan(spanInit: ImageSpan.() -> Unit) { getViewAttr().addSpan(imageSpan) } +/** + * Adds one explicit inline layout group. Children keep their own text/image + * styling while native RichText owns their combined measurement and chrome. + */ +fun RichTextView.InlineBoxGroup( + style: InlineBoxSpanStyle, + groupInit: InlineBoxGroupSpan.() -> Unit, +) { + val group = InlineBoxGroupSpan(style) + group.pagerId = pagerId + group.groupInit() + getViewAttr().addSpan(group) +} + open class RichTextView : DeclarativeBaseView(), MeasureFunction { var shadow: RichTextShadow? = null @@ -315,13 +329,16 @@ open class RichTextView : DeclarativeBaseView(), // 分发span布局位置变化 private fun dispatchPlaceholderSpanLayoutEventIfNeed() { - attr.spans.forEach { child -> - if (child is PlaceholderSpan && child.spanFrameDidChangedHandlerFn != null) { - val placeholderSpan = child - getPager().addTaskWhenPagerUpdateLayoutFinish { - val index = attr.spans.indexOf(placeholderSpan) - if (index >= 0) { - val rectStr = shadow?.callMethod("spanRect", index.toString()) + attr.spans.forEachIndexed { index, child -> + child.visitPlaceholders { childIndex, placeholderSpan -> + if (placeholderSpan.spanFrameDidChangedHandlerFn != null) { + getPager().addTaskWhenPagerUpdateLayoutFinish { + val rectTarget = if (childIndex == null) { + index.toString() + } else { + "$index $childIndex" + } + val rectStr = shadow?.callMethod("spanRect", rectTarget) if (rectStr?.isNotEmpty() == true) { rectStr.split(" ").apply { if (this.size >= 4) { @@ -432,6 +449,108 @@ interface ISpan { abstract fun willDestroy() } +private inline fun ISpan.visitPlaceholders( + visitor: (childIndex: Int?, placeholder: PlaceholderSpan) -> Unit, +) { + when (this) { + is PlaceholderSpan -> visitor(null, this) + is InlineBoxGroupSpan -> children.forEachIndexed { index, child -> + if (child is PlaceholderSpan) visitor(index, child) + } + } +} + +/** + * First-class RichText group. Unlike applying [InlineBoxSpanStyle] to each + * flattened text fragment, this preserves one exact range and its styled + * children across the common/native boundary. + */ +open class InlineBoxGroupSpan( + private val style: InlineBoxSpanStyle, +) : ISpan { + companion object { + const val PROP_KEY_CHILDREN = "inlineBoxChildren" + const val PROP_KEY_SEMANTIC_TEXT = "inlineBoxSemanticText" + } + + var pagerId: String = "" + internal val children = fastArrayListOf() + private var semanticText: String? = null + private var clickHandlerFn: ((ClickParams) -> Unit)? = null + private var longPressHandlerFn: ((LongPressParams) -> Unit)? = null + + fun Span(textSpanInit: TextSpan.() -> Unit) { + val span = TextSpan().apply { + pagerId = this@InlineBoxGroupSpan.pagerId + textSpanInit() + } + if (!span.isEmptySpan()) children.add(span) + } + + fun PlaceholderSpan(spanInit: PlaceholderSpan.() -> Unit) { + val span = PlaceholderSpan().apply(spanInit) + if (!span.isEmptySpan()) children.add(span) + } + + fun addChild(span: ISpan) { + if (!span.isEmptySpan()) children.add(span) + } + + fun childrenForLayout(): List = children + + fun semanticText(text: String) { + semanticText = text + } + + fun click(handler: (ClickParams) -> Unit) { + clickHandlerFn = handler + } + + fun longPress(handler: (LongPressParams) -> Unit) { + longPressHandlerFn = handler + } + + override fun isEmptySpan(): Boolean = children.isEmpty() + + override fun spanPropsMap(): Map = fastHashMapOf().apply { + style.backgroundColor?.let { put(TextConst.INLINE_BOX_BACKGROUND_COLOR, it.toString()) } + style.borderColor?.let { put(TextConst.INLINE_BOX_BORDER_COLOR, it.toString()) } + put(TextConst.INLINE_BOX_BORDER_WIDTH, style.borderWidth) + put(TextConst.INLINE_BOX_PADDING_START, style.paddingStart) + put(TextConst.INLINE_BOX_PADDING_END, style.paddingEnd) + put(TextConst.INLINE_BOX_PADDING_TOP, style.paddingTop) + put(TextConst.INLINE_BOX_PADDING_BOTTOM, style.paddingBottom) + put(TextConst.INLINE_BOX_MARGIN_START, style.marginStart) + put(TextConst.INLINE_BOX_MARGIN_END, style.marginEnd) + put(TextConst.INLINE_BOX_CORNER_RADIUS, style.cornerRadius) + put(PROP_KEY_CHILDREN, children.map { it.spanPropsMap() }) + semanticText?.let { put(PROP_KEY_SEMANTIC_TEXT, it) } + } + + override fun performClickHandler(clickParams: ClickParams): Boolean { + clickHandlerFn?.invoke(clickParams) + if (clickHandlerFn != null) return true + return children.any { it.performClickHandler(clickParams) } + } + + override fun hasClickEvent(): Boolean = + clickHandlerFn != null || children.any { it.hasClickEvent() } + + override fun performLongPressHandler(longPressParams: LongPressParams): Boolean { + longPressHandlerFn?.invoke(longPressParams) + if (longPressHandlerFn != null) return true + return children.any { it.performLongPressHandler(longPressParams) } + } + + override fun hasLongPressEvent(): Boolean = + longPressHandlerFn != null || children.any { it.hasLongPressEvent() } + + override fun willDestroy() { + children.forEach { it.willDestroy() } + children.clear() + } +} + open class TextSpan : TextAttr(), ISpan { internal var text: String = "" private var clickHandlerFn: ((ClickParams) -> Unit)? = null @@ -454,6 +573,21 @@ open class TextSpan : TextAttr(), ISpan { return this } + /** Attach generic inline box decoration to this existing text span. */ + fun inlineBoxStyle(style: InlineBoxSpanStyle): TextSpan { + style.backgroundColor?.let { setProp(TextConst.INLINE_BOX_BACKGROUND_COLOR, it.toString()) } + style.borderColor?.let { setProp(TextConst.INLINE_BOX_BORDER_COLOR, it.toString()) } + setProp(TextConst.INLINE_BOX_BORDER_WIDTH, style.borderWidth) + setProp(TextConst.INLINE_BOX_PADDING_START, style.paddingStart) + setProp(TextConst.INLINE_BOX_PADDING_END, style.paddingEnd) + setProp(TextConst.INLINE_BOX_PADDING_TOP, style.paddingTop) + setProp(TextConst.INLINE_BOX_PADDING_BOTTOM, style.paddingBottom) + setProp(TextConst.INLINE_BOX_MARGIN_START, style.marginStart) + setProp(TextConst.INLINE_BOX_MARGIN_END, style.marginEnd) + setProp(TextConst.INLINE_BOX_CORNER_RADIUS, style.cornerRadius) + return this + } + override fun textDecorationColor(color: Color): TextSpan { TextConst.TEXT_DECORATION_COLOR with color.toString() return this diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index d9af17ee3..bbad4db86 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -574,6 +574,16 @@ object TextConst { const val SLOCK_INLINE_CODE = "slockInlineCode" const val SLOCK_INLINE_CODE_TRAILING_MARGIN = "slockInlineCodeTrailingMargin" const val SLOCK_MARKDOWN_TAG_CHROME = "slockMarkdownTagChrome" + const val INLINE_BOX_BACKGROUND_COLOR = "inlineBoxBackgroundColor" + const val INLINE_BOX_BORDER_COLOR = "inlineBoxBorderColor" + const val INLINE_BOX_BORDER_WIDTH = "inlineBoxBorderWidth" + const val INLINE_BOX_PADDING_START = "inlineBoxPaddingStart" + const val INLINE_BOX_PADDING_END = "inlineBoxPaddingEnd" + const val INLINE_BOX_PADDING_TOP = "inlineBoxPaddingTop" + const val INLINE_BOX_PADDING_BOTTOM = "inlineBoxPaddingBottom" + const val INLINE_BOX_MARGIN_START = "inlineBoxMarginStart" + const val INLINE_BOX_MARGIN_END = "inlineBoxMarginEnd" + const val INLINE_BOX_CORNER_RADIUS = "inlineBoxCornerRadius" const val SHADOW_METHOD_IS_LINE_BREAK_MARGIN = "isLineBreakMargin" const val PLACEHOLDER = "placeholder" diff --git a/docs/API/components/rich-text.md b/docs/API/components/rich-text.md index 428532749..f4fb5337c 100644 --- a/docs/API/components/rich-text.md +++ b/docs/API/components/rich-text.md @@ -191,6 +191,49 @@ internal class TestPage : BasePager() { ::: +### InlineBoxGroup方法 + +`InlineBoxGroup` 用于将多个不同样式的 `Span` / `PlaceholderSpan` 作为一个明确的行内分组进行排版。 +子 Span 仍保留各自的字体、字号、颜色和占位尺寸;原生 RichText 排版引擎负责文本测量、换行、统一背景/边框绘制、点击命中与语义文本。 + +```kotlin +RichText { + InlineBoxGroup( + InlineBoxSpanStyle( + backgroundColor = Color(0xFFFCEFBD), + borderColor = Color.BLACK, + borderWidth = 1f, + paddingStart = 4f, + paddingEnd = 4f, + paddingTop = 1f, + paddingBottom = 1f, + marginStart = 2f, + marginEnd = 2f, + ) + ) { + PlaceholderSpan { + placeholderSize(12f, 12f) + } + Span { + text("#project") + fontSize(14f) + fontWeightBold() + } + Span { + text(" msg") + fontSize(10f) + color(Color(0x80000000)) + } + semanticText("#project msg") + click { + // 整个 group 共用一个点击区域 + } + } +} +``` + +`semanticText` 用于为含占位符或布局辅助字符的 group 提供稳定的复制/无障碍文本。布局辅助字符不会作为 group 身份或业务语义。 + ## 事件 支持[Text组件的所有事件](text.md#事件) @@ -208,6 +251,46 @@ internal class TestPage : BasePager() { `Span.longPress` 从 **2.23.0** 开始支持。 ::: +### Span inline box decoration + +`TextSpan` supports a semantic-agnostic inline box decoration through +`inlineBoxStyle`. The style participates in text measurement and keeps the +original span click/long-press and copied text semantics. + +```kotlin +Span { + text("linked message") + inlineBoxStyle( + InlineBoxSpanStyle( + backgroundColor = Color(0x33FFD440), + borderColor = Color.BLACK, + borderWidth = 1f, + paddingStart = 4f, + paddingEnd = 4f, + paddingTop = 2f, + paddingBottom = 2f, + ) + ) +} +``` + +Compose DSL uses the same existing span carrier: + +```kotlin +SpanStyle( + inlineBoxStyle = InlineBoxSpanStyle( + backgroundColor = Color.Yellow.copy(alpha = 0.2f), + borderColor = Color.Black, + borderWidth = 1.dp, + paddingStart = 4.dp, + paddingEnd = 4.dp, + ) +) +``` + +The decoration is presentation-only. Business meanings such as message, +channel, task, or mention stay in the caller's annotation/action layer. + `RichText` 中的 `Span` / `ImageSpan` 支持单独注册 `longPress` 事件。命中可长按的 span 时,会优先回调该 span 的 `longPress`;如果当前触点未命中任何注册了 `longPress` 的 span,则会回退到 `RichText.longPress`。 `longPress` 回调参数为 `LongPressParams`,字段说明可参考[通用事件文档](basic-attr-event.md)。 From 37cf7093ad5141070acde3e50b95d47987c53419 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 19:11:57 +0800 Subject: [PATCH 095/187] fix(compose): arbitrate native text focus (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compose): arbitrate native text focus Signed-off-by: KMP-专家 * fix(compose): close native focus authority gaps Signed-off-by: Codex-Kuikly-KMP * fix(compose): recover incomplete focus commands Signed-off-by: Codex-Kuikly-KMP --------- Signed-off-by: KMP-专家 Signed-off-by: Codex-Kuikly-KMP Co-authored-by: KMP-专家 Co-authored-by: Codex-Kuikly-KMP --- .../compose/foundation/text/CoreTextField.kt | 73 +++-- .../ui/platform/InputFocusTargetReducer.kt | 199 +++++++++++++ .../ui/platform/SoftwareKeyboardController.kt | 121 +++++--- .../platform/InputFocusTargetReducerTest.kt | 268 ++++++++++++++++++ .../expand/component/KRTextFieldView.kt | 62 +++- .../Extension/Components/KRTextAreaView.m | 50 +++- .../Extension/Components/KRTextFieldView.m | 51 +++- .../components/input/KRTextEditorCommon.h | 8 +- .../input/KRTextEditorFieldView.cpp | 42 ++- .../components/input/KRTextEditorFieldView.h | 4 +- .../components/input/KRTextFieldView.cpp | 37 ++- .../expand/components/input/KRTextFieldView.h | 8 +- .../cpp/libohos_render/utils/KRViewUtil.cpp | 4 +- .../cpp/libohos_render/utils/KRViewUtil.h | 2 +- .../core/views/AutoHeightTextAreaView.kt | 16 +- .../tencent/kuikly/core/views/InputView.kt | 9 +- .../tencent/kuikly/core/views/TextAreaView.kt | 6 +- 17 files changed, 843 insertions(+), 117 deletions(-) create mode 100644 compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt index 9f521f1d9..ccfa65c4c 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt @@ -18,6 +18,7 @@ package com.tencent.kuikly.compose.foundation.text import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.currentComposer import androidx.compose.runtime.currentCompositeKeyHash import androidx.compose.runtime.getValue @@ -53,6 +54,8 @@ import com.tencent.kuikly.compose.ui.platform.LocalDensity import com.tencent.kuikly.compose.ui.platform.LocalFocusManager import com.tencent.kuikly.compose.ui.platform.LocalLayoutDirection import com.tencent.kuikly.compose.ui.platform.LocalSoftwareKeyboardController +import com.tencent.kuikly.compose.ui.platform.InputFocusTargetReducer +import com.tencent.kuikly.compose.ui.platform.KuiklySoftwareKeyboardController import com.tencent.kuikly.compose.ui.platform.SoftwareKeyboardController import com.tencent.kuikly.compose.ui.text.AnnotatedString import com.tencent.kuikly.compose.ui.text.MultiParagraph @@ -189,7 +192,13 @@ internal fun CoreTextField( singleLineNew = keyboardOptions?.keyboardType == KeyboardType.Password } - val autoHeightTextAreaView by remember { mutableStateOf(AutoHeightTextAreaView(singleLineNew)) } + val autoHeightTextAreaView = remember(singleLineNew) { AutoHeightTextAreaView(singleLineNew) } + val kuiklyKeyboardController = keyboardController as? KuiklySoftwareKeyboardController + DisposableEffect(autoHeightTextAreaView, kuiklyKeyboardController) { + onDispose { + kuiklyKeyboardController?.unregisterInput(autoHeightTextAreaView) + } + } var lineHeight by remember { mutableStateOf(0f) } var oldSize by remember { mutableStateOf(IntSize.Zero) } @@ -302,6 +311,11 @@ internal fun CoreTextField( val focusRequester = remember { FocusRequester() } var hasFocus by remember { mutableStateOf(false) } + val state = remember(keyboardController) { + LegacyTextFieldState( + keyboardController = keyboardController + ) + } // Focus val focusModifier = Modifier.textFieldFocusModifier( enabled = enabled, @@ -312,6 +326,7 @@ internal fun CoreTextField( return@textFieldFocusModifier } hasFocus = it.isFocused + state.hasFocus = it.isFocused if (it.isFocused && enabled && !readOnly) { requireOwner().softwareKeyboardController.startInput(autoHeightTextAreaView) @@ -320,20 +335,6 @@ internal fun CoreTextField( } } - val state = remember(keyboardController) { - LegacyTextFieldState( -// TextDelegate( -// text = visualText, -// style = textStyle, -// softWrap = softWrap, -// density = density, -// fontFamilyResolver = fontFamilyResolver -// ), -// recomposeScope = scope, - keyboardController = keyboardController - ) - } - fun dispatchLimitChange(length: Int?, forceNotify: Boolean = false) { val safeLength = length ?: return if (safeLength == -1) return @@ -373,8 +374,39 @@ internal fun CoreTextField( KNode(textView) { getViewAttr().autofocus(false) getViewAttr().enablePinyinCallback(true) - getViewEvent().inputFocus { - focusRequester.requestFocus() + getViewEvent().inputFocus { params -> + val nativeFocusDecision = kuiklyKeyboardController?.onNativeFocus( + autoHeightTextAreaView, + params.focusRequestId, + ) + if (!enabled || readOnly) { + kuiklyKeyboardController?.rejectNativeFocus(autoHeightTextAreaView) + return@inputFocus + } + when (nativeFocusDecision) { + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + null -> { + // Native focus is only an intent. Keep the native editor as + // first responder only after FocusOwner commits the request. + // requestFocus() returns Unit, so it cannot close captured / + // disabled / lifecycle rejection races. + if (!focusRequester.hasAttachedNodes() || !focusRequester.focus()) { + kuiklyKeyboardController?.rejectNativeFocus(autoHeightTextAreaView) + } + } + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale -> Unit + } + } + getViewEvent().inputBlur { params -> + if ( + kuiklyKeyboardController?.onNativeBlur( + autoHeightTextAreaView, + params.focusRequestId, + ) == InputFocusTargetReducer.NativeBlurDecision.RequestComposeClear + ) { + focusManager.clearFocus() + } } } @@ -388,13 +420,6 @@ internal fun CoreTextField( // 从父亲抽取 TextField 相关的Modifier this.modifier = propsAndEvents } - set(hasFocus) { - withTextAreaView { - if (hasFocus) { - focus() - } - } - } set(editable) { withTextAreaView { getViewAttr().editable(editable) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt new file mode 100644 index 000000000..29d8aa380 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt @@ -0,0 +1,199 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.platform + +/** + * Reduces logical Compose focus into one desired native editor target. + * + * Focus is state, not an ordered stream of start/stop commands. In particular, a late stop for + * editor A must not erase a newer start for editor B. Native focus events are observations; events + * without a request id are treated as user focus intents that still need Compose FocusOwner + * approval. + */ +internal class InputFocusTargetReducer { + internal sealed interface Command { + val view: T + val generation: Long + + data class Focus( + override val view: T, + override val generation: Long, + ) : Command + + data class Blur( + override val view: T, + override val generation: Long, + ) : Command + + data class CancelPendingFocus( + override val view: T, + override val generation: Long, + ) : Command + } + + internal enum class NativeFocusDecision { + Confirmed, + RequestComposeFocus, + IgnoreStale, + } + + internal enum class NativeBlurDecision { + Confirmed, + RequestComposeClear, + } + + internal var desiredView: T? = null + private set + internal var observedView: T? = null + private set + internal var generation: Long = 0L + private set + + private var pendingFocusView: T? = null + private var pendingFocusGeneration: Long? = null + private var focusAttemptCount = 0 + private var pendingBlurView: T? = null + + internal fun start(view: T): List> { + if (desiredView === view) return emptyList() + generation += 1 + desiredView = view + focusAttemptCount = 0 + return cancelSupersededPendingFocus(view) + } + + internal fun stop(view: T): List> { + if (desiredView !== view) return emptyList() + generation += 1 + desiredView = null + focusAttemptCount = 0 + return cancelSupersededPendingFocus(null) + } + + internal fun reconcile(): Command? { + val target = desiredView + if (target == null) { + val active = observedView ?: return null + if (pendingBlurView === active) return null + pendingBlurView = active + return Command.Blur(active, generation) + } + if (observedView === target) return null + if (pendingFocusView === target && pendingFocusGeneration == generation) return null + if (focusAttemptCount >= MaxFocusAttemptsPerGeneration) return null + pendingFocusView = target + pendingFocusGeneration = generation + focusAttemptCount += 1 + pendingBlurView = null + return Command.Focus(target, generation) + } + + internal fun onNativeFocus(view: T, requestId: Long?): NativeFocusDecision { + observedView = view + pendingBlurView = null + if (requestId != null) { + val matchesCurrentRequest = + requestId == generation && + desiredView === view && + pendingFocusView === view && + pendingFocusGeneration == requestId + if (!matchesCurrentRequest) { + return NativeFocusDecision.IgnoreStale + } + pendingFocusView = null + pendingFocusGeneration = null + focusAttemptCount = 0 + return NativeFocusDecision.Confirmed + } + + // A native focus event without a request id came from a platform/user focus action. It is + // an intent, not authority: Compose FocusOwner still has to accept it. + if (desiredView === view) { + pendingFocusView = null + pendingFocusGeneration = null + focusAttemptCount = 0 + return NativeFocusDecision.Confirmed + } + return NativeFocusDecision.RequestComposeFocus + } + + internal fun onNativeBlur(view: T, requestId: Long?): NativeBlurDecision { + val shouldClearComposeFocus = requestId == null && desiredView === view + if (observedView === view) observedView = null + if (pendingBlurView === view) pendingBlurView = null + if (requestId != null && pendingFocusView === view && pendingFocusGeneration == requestId) { + pendingFocusView = null + pendingFocusGeneration = null + } + return if (shouldClearComposeFocus) { + NativeBlurDecision.RequestComposeClear + } else { + NativeBlurDecision.Confirmed + } + } + + internal fun unregister(view: T): List> { + val commands = mutableListOf>() + if (desiredView === view) { + generation += 1 + desiredView = null + focusAttemptCount = 0 + } + if (pendingFocusView === view) { + commands += Command.CancelPendingFocus(view, generation) + pendingFocusView = null + pendingFocusGeneration = null + } + if (observedView === view) observedView = null + if (pendingBlurView === view) pendingBlurView = null + return commands + } + + internal fun rejectNativeFocus(view: T) { + if (observedView === view) observedView = null + } + + /** + * Releases a programmatic focus request that produced no native completion callback. + * + * Native renderers can reject a request because their node/window is not ready. The bridge + * command itself has no completion callback, so use a bounded generation-scoped timeout to + * retry without turning a permanently unavailable editor into an unbounded focus storm. + */ + internal fun onFocusRequestTimeout(view: T, requestGeneration: Long): Boolean { + val matchesPendingRequest = + pendingFocusView === view && pendingFocusGeneration == requestGeneration + if (!matchesPendingRequest) return false + pendingFocusView = null + pendingFocusGeneration = null + return desiredView === view && + observedView !== view && + generation == requestGeneration && + focusAttemptCount < MaxFocusAttemptsPerGeneration + } + + private fun cancelSupersededPendingFocus(nextView: T?): List> { + val pending = pendingFocusView ?: return emptyList() + if (pending === nextView) return emptyList() + pendingFocusView = null + pendingFocusGeneration = null + return listOf(Command.CancelPendingFocus(pending, generation)) + } + + private companion object { + const val MaxFocusAttemptsPerGeneration = 3 + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt index 463f1e495..e2bcfc993 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt @@ -64,60 +64,103 @@ interface SoftwareKeyboardController { } internal class KuiklySoftwareKeyboardController : SoftwareKeyboardController { - private enum class PendingAction { - NONE, START_INPUT, STOP_INPUT, SHOW_KEYBOARD, HIDE_KEYBOARD - } - private var activeView: AutoHeightTextAreaView? = null - private var pendingView: AutoHeightTextAreaView? = null - private var pendingAction = PendingAction.NONE - private var scheduleInputCommand = false + private val focusReducer = InputFocusTargetReducer() + private var reconcileScheduled = false + private var keyboardHidden = false override fun show() { - activeView?.also { sendInputCommand(it, PendingAction.SHOW_KEYBOARD) } + keyboardHidden = false + scheduleReconcile(focusReducer.desiredView ?: focusReducer.observedView) } override fun hide() { - activeView?.also { sendInputCommand(it, PendingAction.HIDE_KEYBOARD) } + keyboardHidden = true + scheduleReconcile(focusReducer.desiredView ?: focusReducer.observedView) } internal fun startInput(view: AutoHeightTextAreaView) { - sendInputCommand(view, PendingAction.START_INPUT) + keyboardHidden = false + execute(focusReducer.start(view)) + scheduleReconcile(view) } internal fun stopInput(view: AutoHeightTextAreaView) { - sendInputCommand(view, PendingAction.STOP_INPUT) + execute(focusReducer.stop(view)) + scheduleReconcile(view) + } + + internal fun onNativeFocus( + view: AutoHeightTextAreaView, + requestId: Long?, + ): InputFocusTargetReducer.NativeFocusDecision { + val decision = focusReducer.onNativeFocus(view, requestId) + if (decision == InputFocusTargetReducer.NativeFocusDecision.IgnoreStale) { + scheduleReconcile(view) + } + return decision + } + + internal fun onNativeBlur( + view: AutoHeightTextAreaView, + requestId: Long?, + ): InputFocusTargetReducer.NativeBlurDecision { + val decision = focusReducer.onNativeBlur(view, requestId) + scheduleReconcile(view) + return decision } - private fun sendInputCommand(view: AutoHeightTextAreaView, action: PendingAction) { - if (!scheduleInputCommand) { - scheduleInputCommand = true - setTimeout(view.pagerId) { - scheduleInputCommand = false - when (pendingAction) { - PendingAction.START_INPUT -> { - pendingView?.focus() - activeView = pendingView - } - PendingAction.STOP_INPUT -> { - if (activeView == pendingView) { - activeView?.blur() - activeView = null - } - } - PendingAction.SHOW_KEYBOARD -> { - activeView?.focus() - } - PendingAction.HIDE_KEYBOARD -> { - activeView?.blur() - } - else -> {} - } - pendingAction = PendingAction.NONE - pendingView = null + internal fun rejectNativeFocus(view: AutoHeightTextAreaView) { + focusReducer.rejectNativeFocus(view) + view.blur(focusReducer.generation) + } + + internal fun unregisterInput(view: AutoHeightTextAreaView) { + execute(focusReducer.unregister(view)) + } + + private fun scheduleReconcile(anchor: AutoHeightTextAreaView?) { + val pagerId = anchor?.pagerId ?: return + if (reconcileScheduled) return + reconcileScheduled = true + setTimeout(pagerId) { + reconcileScheduled = false + if (!keyboardHidden || focusReducer.desiredView == null) { + execute(focusReducer.reconcile()) + } + if (keyboardHidden) { + focusReducer.observedView?.blur(focusReducer.generation) } } - pendingView = view - pendingAction = action } + private fun execute(commands: List>) { + commands.forEach(::execute) + } + + private fun execute(command: InputFocusTargetReducer.Command?) { + when (command) { + is InputFocusTargetReducer.Command.Focus -> + executeFocus(command) + is InputFocusTargetReducer.Command.Blur -> + command.view.blur(command.generation) + is InputFocusTargetReducer.Command.CancelPendingFocus -> + command.view.cancelPendingFocus(command.generation) + null -> Unit + } + } + + private fun executeFocus( + command: InputFocusTargetReducer.Command.Focus, + ) { + command.view.focus(command.generation) + setTimeout(command.view.pagerId, FocusCompletionTimeoutMs) { + if (focusReducer.onFocusRequestTimeout(command.view, command.generation)) { + scheduleReconcile(command.view) + } + } + } + + private companion object { + const val FocusCompletionTimeoutMs = 120 + } } diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt new file mode 100644 index 000000000..7820e1e7f --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt @@ -0,0 +1,268 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.platform + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class InputFocusTargetReducerTest { + private class View(val name: String) + + @Test + fun lateStopForOldViewDoesNotEraseNewDesiredView() { + val reducer = InputFocusTargetReducer() + val first = View("first") + val second = View("second") + + reducer.start(first) + val firstFocus = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(first, firstFocus.generation) + + reducer.start(second) + reducer.stop(first) + + assertSame(second, reducer.desiredView) + val secondFocus = assertIs>(reducer.reconcile()) + assertSame(second, secondFocus.view) + } + + @Test + fun bothFocusTransferCallbackOrdersConvergeOnSecondView() { + val first = View("first") + val second = View("second") + + val stopThenStart = InputFocusTargetReducer() + stopThenStart.start(first) + stopThenStart.onNativeFocus( + first, + assertIs>(stopThenStart.reconcile()).generation, + ) + stopThenStart.stop(first) + stopThenStart.start(second) + + val startThenStop = InputFocusTargetReducer() + startThenStop.start(first) + startThenStop.onNativeFocus( + first, + assertIs>(startThenStop.reconcile()).generation, + ) + startThenStop.start(second) + startThenStop.stop(first) + + assertSame(second, stopThenStart.desiredView) + assertSame(second, startThenStop.desiredView) + assertSame( + second, + assertIs>(stopThenStart.reconcile()).view, + ) + assertSame( + second, + assertIs>(startThenStop.reconcile()).view, + ) + } + + @Test + fun staleProgrammaticFocusIsRejectedAndCurrentTargetIsReconciled() { + val reducer = InputFocusTargetReducer() + val first = View("first") + val second = View("second") + + reducer.start(first) + val firstRequest = assertIs>(reducer.reconcile()) + val cancelCommands = reducer.start(second) + + assertTrue(cancelCommands.any { it is InputFocusTargetReducer.Command.CancelPendingFocus }) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale, + reducer.onNativeFocus(first, firstRequest.generation), + ) + val currentRequest = assertIs>(reducer.reconcile()) + assertSame(second, currentRequest.view) + assertEquals(reducer.generation, currentRequest.generation) + } + + @Test + fun nativeFocusFailureCanRetrySameDesiredViewAfterLifecycleRecovers() { + val reducer = InputFocusTargetReducer() + val view = View("not-ready-then-ready") + + reducer.start(view) + val failedRequest = assertIs>(reducer.reconcile()) + + assertTrue(reducer.onFocusRequestTimeout(view, failedRequest.generation)) + val retry = assertIs>(reducer.reconcile()) + assertSame(view, retry.view) + assertEquals(failedRequest.generation, retry.generation) + + reducer.onNativeFocus(view, retry.generation) + assertSame(view, reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun nativeFocusFailureRetryIsBoundedWithinOneGeneration() { + val reducer = InputFocusTargetReducer() + val view = View("permanently-unavailable") + + reducer.start(view) + repeat(2) { + val request = assertIs>(reducer.reconcile()) + assertTrue(reducer.onFocusRequestTimeout(view, request.generation)) + } + val lastRequest = assertIs>(reducer.reconcile()) + assertEquals(false, reducer.onFocusRequestTimeout(view, lastRequest.generation)) + assertNull(reducer.reconcile()) + } + + @Test + fun nativeUserFocusRequiresComposeApproval() { + val reducer = InputFocusTargetReducer() + val first = View("first") + val second = View("second") + + reducer.start(first) + val firstRequest = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(first, firstRequest.generation) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + reducer.onNativeFocus(second, requestId = null), + ) + assertSame(first, reducer.desiredView) + assertSame(second, reducer.observedView) + + reducer.start(second) + assertNull(reducer.reconcile()) + } + + @Test + fun rejectedNativeUserFocusDoesNotBecomeObservedAuthority() { + val reducer = InputFocusTargetReducer() + val approved = View("approved") + val rejected = View("rejected") + + reducer.start(approved) + val approvedRequest = + assertIs>(reducer.reconcile()) + reducer.onNativeFocus(approved, approvedRequest.generation) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + reducer.onNativeFocus(rejected, requestId = null), + ) + reducer.rejectNativeFocus(rejected) + + assertNull(reducer.observedView) + assertSame(approved, reducer.desiredView) + assertSame( + approved, + assertIs>(reducer.reconcile()).view, + ) + } + + @Test + fun nativeUserBlurOnlyClearsComposeWhenItStillOwnsDesiredFocus() { + val reducer = InputFocusTargetReducer() + val first = View("first") + val second = View("second") + + reducer.start(first) + val firstRequest = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(first, firstRequest.generation) + + assertEquals( + InputFocusTargetReducer.NativeBlurDecision.RequestComposeClear, + reducer.onNativeBlur(first, requestId = null), + ) + + reducer.start(second) + assertEquals( + InputFocusTargetReducer.NativeBlurDecision.Confirmed, + reducer.onNativeBlur(first, requestId = null), + ) + } + + @Test + fun unregisterClearsDetachedDesiredAndObservedView() { + val reducer = InputFocusTargetReducer() + val view = View("detached") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, request.generation) + + reducer.unregister(view) + + assertNull(reducer.desiredView) + assertNull(reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun unregisterCancelsFocusQueuedForDetachedView() { + val reducer = InputFocusTargetReducer() + val detached = View("detached") + + reducer.start(detached) + reducer.reconcile() + val commands = reducer.unregister(detached) + + assertTrue(commands.single() is InputFocusTargetReducer.Command.CancelPendingFocus) + assertNull(reducer.desiredView) + assertNull(reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun hideShowStyleBlurKeepsDesiredTargetForRefocus() { + val reducer = InputFocusTargetReducer() + val view = View("editor") + + reducer.start(view) + val focus = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, focus.generation) + reducer.onNativeBlur(view, requestId = reducer.generation) + + assertSame(view, reducer.desiredView) + assertSame(view, assertIs>(reducer.reconcile()).view) + } + + @Test + fun blurWithoutProgrammaticCallbackStillLetsLaterUserBlurClearCompose() { + val reducer = InputFocusTargetReducer() + val view = View("editor") + + reducer.start(view) + val firstFocus = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, firstFocus.generation) + reducer.stop(view) + assertIs>(reducer.reconcile()) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + reducer.onNativeFocus(view, requestId = null), + ) + reducer.start(view) + assertEquals( + InputFocusTargetReducer.NativeBlurDecision.RequestComposeClear, + reducer.onNativeBlur(view, requestId = null), + ) + } +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt index fd7b814d1..6165b9267 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt @@ -143,6 +143,8 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : * 如果这两者没处于 focus 时,收到显示键盘的请求, lazy 住,等两者都 focus 时,才显示键盘 */ private var pendingFocus = false + private var pendingFocusRequestId: Long? = null + private var pendingBlurRequestId: Long? = null private var currentKeyboardHeight = 0 private var lengthLimitType: Int = -1 @@ -268,8 +270,9 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? { return when (method) { METHOD_SET_TEXT -> setInputText(params) - METHOD_FOCUS -> setFocus() - METHOD_BLUR -> setBlur() + METHOD_FOCUS -> setFocus(params) + METHOD_BLUR -> setBlur(params) + METHOD_CANCEL_PENDING_FOCUS -> cancelPendingFocus() METHOD_GET_CURSOR_INDEX -> getCursorIndex(callback) METHOD_SET_CURSOR_INDEX -> setCursorIndex(params) METHOD_SET_TEXT_INPUT_STATE -> setTextInputState(params) @@ -618,10 +621,22 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : return true } - private fun setFocus() { + private fun setFocus(params: String? = null) { + pendingFocusRequestId = params?.toLongOrNull() + pendingBlurRequestId = null isFocusable = true isFocusableInTouchMode = true - requestFocus() + if (hasFocus()) { + pendingFocusRequestId = null + showKeyboard() + return + } + if (!requestFocus()) { + // A failed/no-op command must not label a later real user callback as programmatic. + pendingFocusRequestId = null + pendingFocus = false + return + } post { if (hasWindowFocus() && hasFocus()) { showKeyboard() @@ -652,14 +667,30 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } - private fun setBlur() { + private fun setBlur(params: String? = null) { + pendingBlurRequestId = params?.toLongOrNull() + pendingFocusRequestId = null + pendingFocus = false + if (!hasFocus()) { + pendingBlurRequestId = null + } clearFocus() + if (hasFocus()) { + pendingBlurRequestId = null + } post { - val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(windowToken, 0) + if (rootView.findFocus() == null) { + val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) + } } } + private fun cancelPendingFocus() { + pendingFocusRequestId = null + pendingFocus = false + } + private fun getCursorIndex(callback: KuiklyRenderCallback?) { callback?.invoke(mapOf( "cursorIndex" to cursorIndex @@ -796,9 +827,14 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : setOnFocusChangeListener { _, focus -> if (focus) { - inputFocusCallback?.invoke(createCallbackParamMap()) + pendingBlurRequestId = null + inputFocusCallback?.invoke(createFocusCallbackParamMap(pendingFocusRequestId)) + pendingFocusRequestId = null } else { - inputBlurCallback?.invoke(createCallbackParamMap()) + pendingFocusRequestId = null + pendingFocus = false + inputBlurCallback?.invoke(createFocusCallbackParamMap(pendingBlurRequestId)) + pendingBlurRequestId = null } } return true @@ -858,6 +894,12 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : return textInputSyncRevisionState.snapshot(result) } + private fun createFocusCallbackParamMap(requestId: Long?): Map { + val result = createCallbackParamMap().toMutableMap() + requestId?.let { result[KEY_FOCUS_REQUEST_ID] = it } + return result + } + private fun createTextInputStateParamMap(): Map { val rawText = text?.toString() ?: KRCssConst.EMPTY_STRING val selectionStart = selectionStart.coerceIn(0, rawText.length) @@ -1078,6 +1120,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : private const val METHOD_SET_TEXT = "setText" private const val METHOD_FOCUS = "focus" private const val METHOD_BLUR = "blur" + private const val METHOD_CANCEL_PENDING_FOCUS = "cancelPendingFocus" private const val METHOD_GET_CURSOR_INDEX = "getCursorIndex" private const val METHOD_SET_CURSOR_INDEX = "setCursorIndex" private const val METHOD_SET_TEXT_INPUT_STATE = "setTextInputState" @@ -1093,6 +1136,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : private const val KEY_SELECTION_START = "selectionStart" private const val KEY_SELECTION_END = "selectionEnd" private const val KEY_COMPOSITION_START = "compositionStart" + private const val KEY_FOCUS_REQUEST_ID = "focusRequestId" private const val KEY_COMPOSITION_END = "compositionEnd" private const val KEY_LENGTH = "length" private const val KEY_SYNC_REVISION = "syncRevision" diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index e2da95ed7..9396cddeb 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -113,6 +113,9 @@ - (void)p_updateFont; @implementation KRTextAreaView { NSString *_text; BOOL _didAddKeyboardNotification; + NSNumber *_pendingFocusRequestId; + NSNumber *_pendingBlurRequestId; + NSUInteger _focusRequestEpoch; NSInteger _textInputSyncRevision; NSMutableDictionary *_props; BOOL _ignoreTextDidChanged; @@ -357,13 +360,40 @@ - (void)setCss_autoHideKeyboardOnImeAction:(NSNumber *)css_autoHideKeyboardOnIme #pragma mark - css method - (void)css_focus:(NSDictionary *)args { + NSString *rawRequestId = args[KRC_PARAM_KEY]; + NSNumber *requestId = rawRequestId.length > 0 ? @([rawRequestId longLongValue]) : nil; + NSUInteger requestEpoch = ++_focusRequestEpoch; + _pendingFocusRequestId = requestId; + _pendingBlurRequestId = nil; dispatch_async(dispatch_get_main_queue(), ^{ - [self becomeFirstResponder]; + // Keep cancellation independent from the optional request id so legacy focus(nil) can be + // invalidated before this main-queue block runs. + if (requestEpoch != self->_focusRequestEpoch) { + return; + } + if (self.isFirstResponder) { + self->_pendingFocusRequestId = nil; + return; + } + if (![self becomeFirstResponder] && requestEpoch == self->_focusRequestEpoch) { + self->_pendingFocusRequestId = nil; + } }); } - (void)css_blur:(NSDictionary *)args { - [self resignFirstResponder]; + ++_focusRequestEpoch; + NSString *rawRequestId = args[KRC_PARAM_KEY]; + _pendingBlurRequestId = rawRequestId.length > 0 ? @([rawRequestId longLongValue]) : nil; + _pendingFocusRequestId = nil; + if (!self.isFirstResponder || ![self resignFirstResponder]) { + _pendingBlurRequestId = nil; + } +} + +- (void)css_cancelPendingFocus:(NSDictionary *)args { + ++_focusRequestEpoch; + _pendingFocusRequestId = nil; } - (void)css_getCursorIndex:(NSDictionary *)args { @@ -1013,16 +1043,28 @@ - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range r } - (void)textViewDidBeginEditing:(UITextView *)textView { // 获焦 + _pendingBlurRequestId = nil; if (self.css_inputFocus) { - self.css_inputFocus(@{@"text": textView.text.copy ?: @""}); + NSMutableDictionary *payload = [@{@"text": textView.text.copy ?: @""} mutableCopy]; + if (_pendingFocusRequestId) { + payload[@"focusRequestId"] = _pendingFocusRequestId; + } + self.css_inputFocus(payload); } + _pendingFocusRequestId = nil; } - (void)textViewDidEndEditing:(UITextView *)textView{ // 失焦 + _pendingFocusRequestId = nil; if (self.css_inputBlur) { - self.css_inputBlur(@{@"text": textView.text.copy ?: @""}); + NSMutableDictionary *payload = [@{@"text": textView.text.copy ?: @""} mutableCopy]; + if (_pendingBlurRequestId) { + payload[@"focusRequestId"] = _pendingBlurRequestId; + } + self.css_inputBlur(payload); } + _pendingBlurRequestId = nil; } #pragma mark - notication diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index f7e5442d8..ffaae3c09 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -97,6 +97,9 @@ @implementation KRTextFieldView { BOOL _setNeedUpdatePlaceholder; /** maxTextLength backing store */ NSNumber *_css_maxTextLength; + NSNumber *_pendingFocusRequestId; + NSNumber *_pendingBlurRequestId; + NSUInteger _focusRequestEpoch; /** suppress native selection callback during programmatic selection updates */ BOOL _ignoreSelectionChange; /** suppress intermediate textInputStateChange during programmatic state sync */ @@ -295,13 +298,41 @@ - (void)setCss_enablePinyinCallback:(NSNumber *)css_enablePinyinCallback { #pragma mark - css method - (void)css_focus:(NSDictionary *)args { + NSString *rawRequestId = args[KRC_PARAM_KEY]; + NSNumber *requestId = rawRequestId.length > 0 ? @([rawRequestId longLongValue]) : nil; + NSUInteger requestEpoch = ++_focusRequestEpoch; + _pendingFocusRequestId = requestId; + _pendingBlurRequestId = nil; dispatch_async(dispatch_get_main_queue(), ^{ - [self becomeFirstResponder]; + // The epoch is the cancellation token. A nullable request id cannot serve this purpose: + // legacy focus(nil) followed by blur/cancel would otherwise compare nil == nil and revive + // a stale first responder on the next main-queue drain. + if (requestEpoch != self->_focusRequestEpoch) { + return; + } + if (self.isFirstResponder) { + self->_pendingFocusRequestId = nil; + return; + } + if (![self becomeFirstResponder] && requestEpoch == self->_focusRequestEpoch) { + self->_pendingFocusRequestId = nil; + } }); } - (void)css_blur:(NSDictionary *)args { - [self resignFirstResponder]; + ++_focusRequestEpoch; + NSString *rawRequestId = args[KRC_PARAM_KEY]; + _pendingBlurRequestId = rawRequestId.length > 0 ? @([rawRequestId longLongValue]) : nil; + _pendingFocusRequestId = nil; + if (!self.isFirstResponder || ![self resignFirstResponder]) { + _pendingBlurRequestId = nil; + } +} + +- (void)css_cancelPendingFocus:(NSDictionary *)args { + ++_focusRequestEpoch; + _pendingFocusRequestId = nil; } - (void)css_setText:(NSDictionary *)args { @@ -500,15 +531,27 @@ - (void)onTextFeildTextChanged:(UITextField *)textField { // 文本值变化 - (void)textFieldDidBeginEditing:(UITextField *)textField { // 聚焦 + _pendingBlurRequestId = nil; if (self.css_inputFocus) { - self.css_inputFocus(@{@"text": textField.text.copy ?: @""}); + NSMutableDictionary *payload = [@{@"text": textField.text.copy ?: @""} mutableCopy]; + if (_pendingFocusRequestId) { + payload[@"focusRequestId"] = _pendingFocusRequestId; + } + self.css_inputFocus(payload); } + _pendingFocusRequestId = nil; } - (void)textFieldDidEndEditing:(UITextField *)textField { // 失焦 + _pendingFocusRequestId = nil; if (self.css_inputBlur) { - self.css_inputBlur(@{@"text": textField.text.copy ?: @""}); + NSMutableDictionary *payload = [@{@"text": textField.text.copy ?: @""} mutableCopy]; + if (_pendingBlurRequestId) { + payload[@"focusRequestId"] = _pendingBlurRequestId; + } + self.css_inputBlur(payload); } + _pendingBlurRequestId = nil; } - (void)textFieldDidChangeSelection:(UITextField *)textField { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorCommon.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorCommon.h index 87331ce43..eab5f5f8c 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorCommon.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorCommon.h @@ -282,6 +282,7 @@ static constexpr const char *kTextInputState = "textInputState"; static constexpr const char kMethodFocus[] = "focus"; static constexpr const char kMethodBlur[] = "blur"; +static constexpr const char kMethodCancelPendingFocus[] = "cancelPendingFocus"; static constexpr const char kMethodSetText[] = "setText"; static constexpr const char kMethodGetCursorIndex[] = "getCursorIndex"; static constexpr const char kMethodSetCursorIndex[] = "setCursorIndex"; @@ -346,6 +347,8 @@ struct KRTextEditorState { // 抑制 textDidChange / textInputStateChange / selectionChange 三个回调, // 避免业务层 set->callback->set 形成回环。 bool is_setting_text_input_state_ = false; + int64_t pending_focus_request_id_ = 0; + int64_t pending_blur_request_id_ = 0; KRRenderCallback text_did_change_callback_; KRRenderCallback input_focus_callback_; @@ -772,10 +775,11 @@ inline void UpdateSingleLine(ArkUI_NodeHandle node, bool single_line) { } // Focus / Blur:使用通用 NODE_FOCUS_STATUS。 -inline void UpdateFocusStatus(ArkUI_NodeHandle node, bool focus) { +inline bool UpdateFocusStatus(ArkUI_NodeHandle node, bool focus) { ArkUI_NumberValue value = {.i32 = focus ? 1 : 0}; ArkUI_AttributeItem item = {&value, 1}; - kuikly::util::GetNodeApi()->setAttribute(node, NODE_FOCUS_STATUS, &item); + return kuikly::util::GetNodeApi()->setAttribute(node, NODE_FOCUS_STATUS, &item) == + ARKUI_ERROR_CODE_NO_ERROR; } // focusable diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.cpp index da15eaed2..02bd6f322 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.cpp @@ -421,9 +421,11 @@ void KRTextEditorFieldView::CallMethod(const std::string &method, const KRAnyVal #else using namespace kuikly::text_editor; if (kuikly::util::isEqual(method, kMethodFocus)) { - Focus(); + Focus(params ? params->toLong() : 0); } else if (kuikly::util::isEqual(method, kMethodBlur)) { - Blur(); + Blur(params ? params->toLong() : 0); + } else if (kuikly::util::isEqual(method, kMethodCancelPendingFocus)) { + state_.pending_focus_request_id_ = 0; } else if (kuikly::util::isEqual(method, kMethodSetText)) { SetContentText(params->toString()); } else if (kuikly::util::isEqual(method, kMethodGetCursorIndex)) { @@ -503,19 +505,31 @@ void KRTextEditorFieldView::SetSelectionStartPosition(uint32_t index) { #endif } -void KRTextEditorFieldView::Focus() { +void KRTextEditorFieldView::Focus(int64_t request_id) { #if KUIKLY_TEXT_EDITOR_AVAILABLE - kuikly::text_editor::UpdateFocusStatus(GetNode(), true); + state_.pending_focus_request_id_ = request_id; + state_.pending_blur_request_id_ = 0; + if (!kuikly::text_editor::UpdateFocusStatus(GetNode(), true)) { + state_.pending_focus_request_id_ = 0; + } #endif } -void KRTextEditorFieldView::Blur() { +void KRTextEditorFieldView::Blur(int64_t request_id) { #if KUIKLY_TEXT_EDITOR_AVAILABLE + state_.pending_blur_request_id_ = request_id; + state_.pending_focus_request_id_ = 0; // 优先走 controller 的 StopEditing(更精准收键盘),再 fallback 到 FocusStatus - if (state_.controller_) { - OH_ArkUI_TextEditorStyledStringController_StopEditing(state_.controller_); - } else { - kuikly::text_editor::UpdateFocusStatus(GetNode(), false); + bool requested = false; + if (state_.controller_ && OH_ArkUI_TextEditorStyledStringController_StopEditing) { + requested = OH_ArkUI_TextEditorStyledStringController_StopEditing(state_.controller_) == + ARKUI_ERROR_CODE_NO_ERROR; + } + if (!requested) { + requested = kuikly::text_editor::UpdateFocusStatus(GetNode(), false); + } + if (!requested) { + state_.pending_blur_request_id_ = 0; } #endif } @@ -609,21 +623,31 @@ void KRTextEditorFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { void KRTextEditorFieldView::OnInputFocus(ArkUI_NodeEvent *event) { (void)event; + state_.pending_blur_request_id_ = 0; if (state_.input_focus_callback_) { KRRenderValueMap map; // 上抛 raw 而非 flat(与 textDidChange 一致),避免业务拿到带占位空格的字符串。 map["text"] = NewKRRenderValue(state_.cached_text_); + if (state_.pending_focus_request_id_ > 0) { + map["focusRequestId"] = NewKRRenderValue(state_.pending_focus_request_id_); + } state_.input_focus_callback_(NewKRRenderValue(map)); } + state_.pending_focus_request_id_ = 0; } void KRTextEditorFieldView::OnInputBlur(ArkUI_NodeEvent *event) { (void)event; + state_.pending_focus_request_id_ = 0; if (state_.input_blur_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(state_.cached_text_); + if (state_.pending_blur_request_id_ > 0) { + map["focusRequestId"] = NewKRRenderValue(state_.pending_blur_request_id_); + } state_.input_blur_callback_(NewKRRenderValue(map)); } + state_.pending_blur_request_id_ = 0; } void KRTextEditorFieldView::OnInputReturn(ArkUI_NodeEvent *event) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.h index 972d02bac..04a04252f 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextEditorFieldView.h @@ -75,8 +75,8 @@ class KRTextEditorFieldView : public IKRRenderViewExport { void SetSelectionStartPosition(uint32_t index); // Focus/Blur - void Focus(); - void Blur(); + void Focus(int64_t request_id = 0); + void Blur(int64_t request_id = 0); void GetCursorIndex(const KRRenderCallback &callback); void SetCursorIndex(uint32_t index); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp index 36578c321..82e22da1b 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp @@ -51,6 +51,7 @@ constexpr char kPropTextInputState[] = "textInputState"; // 受控组件模式 constexpr char kMethodFocus[] = "focus"; constexpr char kMethodBlur[] = "blur"; +constexpr char kMethodCancelPendingFocus[] = "cancelPendingFocus"; constexpr char kMethodSetText[] = "setText"; constexpr char kMethodGetCursorIndex[] = "getCursorIndex"; constexpr char kMethodSetCursorIndex[] = "setCursorIndex"; @@ -137,8 +138,8 @@ void KRTextFieldView::UpdateInputNodeEnterKeyType(const std::string& propValue){ void KRTextFieldView::UpdateInputNodeMaxLength(int maxLength){ kuikly::util::UpdateInputNodeMaxLength(GetNode(), maxLength); // 直接限制 } -void KRTextFieldView::UpdateInputNodeFocusStatus(int status){ - kuikly::util::UpdateInputNodeFocusStatus(GetNode(), status); +bool KRTextFieldView::UpdateInputNodeFocusStatus(int status){ + return kuikly::util::UpdateInputNodeFocusStatus(GetNode(), status); } uint32_t KRTextFieldView::GetInputNodeSelectionStartPosition(){ return kuikly::util::GetInputNodeSelectionStartPosition(GetNode()); @@ -347,9 +348,11 @@ void KRTextFieldView::OnEvent(ArkUI_NodeEvent *event, const ArkUI_NodeEventType void KRTextFieldView::CallMethod(const std::string &method, const KRAnyValue ¶ms, const KRRenderCallback &callback) { if (kuikly::util::isEqual(method, kMethodFocus)) { // 获焦 - Focus(); + Focus(params ? params->toLong() : 0); } else if (kuikly::util::isEqual(method, kMethodBlur)) { // 失焦 - Blur(); + Blur(params ? params->toLong() : 0); + } else if (kuikly::util::isEqual(method, kMethodCancelPendingFocus)) { + pending_focus_request_id_ = 0; } else if (kuikly::util::isEqual(method, kMethodSetText)) { // 主动设置文本 SetContentText(params->toString()); } else if (kuikly::util::isEqual(method, kMethodGetCursorIndex)) { // 获取光标位置 @@ -368,15 +371,23 @@ void KRTextFieldView::CallMethod(const std::string &method, const KRAnyValue &pa /** * 输入框获焦(弹起键盘) */ -void KRTextFieldView::Focus() { - UpdateInputNodeFocusStatus(1); +void KRTextFieldView::Focus(int64_t request_id) { + pending_focus_request_id_ = request_id; + pending_blur_request_id_ = 0; + if (!UpdateInputNodeFocusStatus(1)) { + pending_focus_request_id_ = 0; + } } /** * 输入框失焦(收起键盘) */ -void KRTextFieldView::Blur() { - UpdateInputNodeFocusStatus(0); +void KRTextFieldView::Blur(int64_t request_id) { + pending_blur_request_id_ = request_id; + pending_focus_request_id_ = 0; + if (!UpdateInputNodeFocusStatus(0)) { + pending_blur_request_id_ = 0; + } } /** @@ -602,21 +613,31 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { * 获焦回调 */ void KRTextFieldView::OnInputFocus(ArkUI_NodeEvent *event) { + pending_blur_request_id_ = 0; if (input_focus_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(GetContentText()); + if (pending_focus_request_id_ > 0) { + map["focusRequestId"] = NewKRRenderValue(pending_focus_request_id_); + } input_focus_callback_(NewKRRenderValue(map)); } + pending_focus_request_id_ = 0; } /** * 失焦回调 */ void KRTextFieldView::OnInputBlur(ArkUI_NodeEvent *event) { + pending_focus_request_id_ = 0; if (input_blur_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(GetContentText()); + if (pending_blur_request_id_ > 0) { + map["focusRequestId"] = NewKRRenderValue(pending_blur_request_id_); + } input_blur_callback_(NewKRRenderValue(map)); } + pending_blur_request_id_ = 0; } /** * 按下完成键回调 diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h index 6268ddbfd..568862d37 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h @@ -80,7 +80,7 @@ class KRTextFieldView : public IKRRenderViewExport { virtual void UpdateInputNodeKeyboardType(const std::string &propValue); virtual void UpdateInputNodeEnterKeyType(const std::string &propValue); virtual void UpdateInputNodeMaxLength(int maxLength); - virtual void UpdateInputNodeFocusStatus(int status); + virtual bool UpdateInputNodeFocusStatus(int status); virtual uint32_t GetInputNodeSelectionStartPosition(); virtual void UpdateInputNodeSelectionStartPosition(uint32_t index); /** @@ -121,16 +121,18 @@ class KRTextFieldView : public IKRRenderViewExport { KRRenderCallback selection_change_callback_; // 选区变化callback(与 Android KRTextFieldView.selectionChangeCallback 对齐) bool auto_hide_KeyBoard_on_ImeAction_ = false; // 在触发各种IME 按钮时是否回收键盘,默认是不回收 bool is_setting_text_input_state_ = false; // 通过 setTextInputState 主动写入期间,抑制 textInputStateChange 回流防止业务死循环 + int64_t pending_focus_request_id_ = 0; + int64_t pending_blur_request_id_ = 0; /** * 输入框获焦(弹起键盘) */ - void Focus(); + void Focus(int64_t request_id = 0); /** * 输入框失焦(收起键盘) */ - void Blur(); + void Blur(int64_t request_id = 0); /** * 获取光标位置 diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp index 6ec98c7ad..815d255ee 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp @@ -924,10 +924,10 @@ void SetArkUIPadding(ArkUI_NodeHandle handle, float start, float top, float end, GetNodeApi()->setAttribute(handle, NODE_PADDING, &item); } -void UpdateInputNodeFocusStatus(ArkUI_NodeHandle node, int32_t status) { +bool UpdateInputNodeFocusStatus(ArkUI_NodeHandle node, int32_t status) { ArkUI_NumberValue value[] = {{.i32 = status}}; ArkUI_AttributeItem item = {value, sizeof(value) / sizeof(ArkUI_NumberValue)}; - GetNodeApi()->setAttribute(node, NODE_FOCUS_STATUS, &item); + return GetNodeApi()->setAttribute(node, NODE_FOCUS_STATUS, &item) == ARKUI_ERROR_CODE_NO_ERROR; } void UpdateInputNodeFocusable(ArkUI_NodeHandle node, int32_t enable) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h index a5fca98c8..2f9eb886d 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h @@ -196,7 +196,7 @@ void SetArkUIMargin(ArkUI_NodeHandle handle, float start, float top, float end, void SetArkUIPadding(ArkUI_NodeHandle handle, float start, float top, float end, float bottom); -void UpdateInputNodeFocusStatus(ArkUI_NodeHandle node, int32_t status); +bool UpdateInputNodeFocusStatus(ArkUI_NodeHandle node, int32_t status); void UpdateInputNodeFocusable(ArkUI_NodeHandle node, int32_t enable); diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/AutoHeightTextAreaView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/AutoHeightTextAreaView.kt index 632483f05..9a2bf6aa8 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/AutoHeightTextAreaView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/AutoHeightTextAreaView.kt @@ -92,15 +92,21 @@ class AutoHeightTextAreaView(val singleLine: Boolean = false) : return true } - fun focus() { + fun focus(requestId: Long? = null) { performTaskWhenRenderViewDidLoad { - renderView?.callMethod("focus", "") + renderView?.callMethod("focus", requestId?.toString().orEmpty()) } } - fun blur() { + fun blur(requestId: Long? = null) { performTaskWhenRenderViewDidLoad { - renderView?.callMethod("blur", "") + renderView?.callMethod("blur", requestId?.toString().orEmpty()) + } + } + + fun cancelPendingFocus(requestId: Long) { + performTaskWhenRenderViewDidLoad { + renderView?.callMethod("cancelPendingFocus", requestId.toString()) } } @@ -244,4 +250,4 @@ class AutoHeightTextAreaView(val singleLine: Boolean = false) : remeasureText(text) } -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt index 033866239..9c2fee87c 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt @@ -395,7 +395,8 @@ data class InputParams( val text: String, val imeAction: String? = null, val length: Int? = null, - val syncRevision: Int? = null + val syncRevision: Int? = null, + val focusRequestId: Long? = null, ) data class KeyboardParams( @@ -459,7 +460,8 @@ class InputEvent : Event() { register(INPUT_FOCUS){ it as JSONObject val text = it.optString("text") - handler(InputParams(text)) + val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } + handler(InputParams(text, focusRequestId = focusRequestId)) } } @@ -471,7 +473,8 @@ class InputEvent : Event() { register(INPUT_BLUR){ it as JSONObject val text = it.optString("text") - handler(InputParams(text)) + val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } + handler(InputParams(text, focusRequestId = focusRequestId)) } } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt index 834fb9c51..f0ef865b0 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt @@ -723,7 +723,8 @@ open class TextAreaEvent : Event() { this.register(INPUT_FOCUS){ it as JSONObject val text = it.optString("text") - handler(InputParams(text)) + val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } + handler(InputParams(text, focusRequestId = focusRequestId)) } } /** @@ -734,7 +735,8 @@ open class TextAreaEvent : Event() { this.register(INPUT_BLUR){ it as JSONObject val text = it.optString("text") - handler(InputParams(text)) + val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } + handler(InputParams(text, focusRequestId = focusRequestId)) } } From d7172e1b6906a84f0ee80d0fdcb1ca80b91fb212 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 19:39:50 +0800 Subject: [PATCH 096/187] fix(ohos): publish complete text state first (#24) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../expand/components/input/KRTextFieldView.cpp | 14 +++++++------- .../expand/components/input/KRTextFieldView.h | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp index 82e22da1b..4bfb90417 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp @@ -523,7 +523,7 @@ void KRTextFieldView::GetTextInputStateInternal(const KRRenderCallback &callback } /** - * 在 OnTextDidChanged 末尾按需触发 textInputStateChange。 + * 在 OnTextDidChanged 中按需触发 textInputStateChange。 * 主动写入期间通过 is_setting_text_input_state_ 抑制,避免业务死循环。 */ void KRTextFieldView::NotifyTextInputStateChange() { @@ -566,12 +566,11 @@ void KRTextFieldView::NotifySelectionChange() { * 「触发信号」存在;如果未来发现 attribute 读取与事件值不一致带来体感问题, * 可以改为优先使用 event 参数构造 map。 * - * 我们同时触发 selectionChange 与 textInputStateChange,与 Compose `CoreTextField` - * 业务侧期望的「选区变化即可拿到完整 state」语义对齐。 + * selectionChange 已经携带完整 state;与 Android onSelectionChanged 对齐, + * 这里只发一次,避免 Compose 对同一选区变化连续处理两份等价状态。 */ void KRTextFieldView::OnTextSelectionChange(ArkUI_NodeEvent *event) { NotifySelectionChange(); - NotifyTextInputStateChange(); } /** @@ -593,6 +592,10 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { LimitInputContentTextInMaxLength(); drag_entered_ = false; } + // Android afterTextChanged 先发带 selection 的完整 state,再发 legacy textDidChange。 + // Compose 依赖这个顺序跳过不含 selection 的 fallback;如果反过来, + // 新文本会先被配上 (0, 0) 选区回灌 native,导致光标跳到最前面。 + NotifyTextInputStateChange(); if (text_did_change_callback_) { auto text = GetContentText(); KRRenderValueMap map; @@ -604,9 +607,6 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { } text_did_change_callback_(NewKRRenderValue(map)); } - // 同一时机触发 textInputStateChange(与 Android KRTextFieldView 一致)。 - // 主动写入期间由 NotifyTextInputStateChange 内部抑制,避免业务回流。 - NotifyTextInputStateChange(); } /** diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h index 568862d37..37898e447 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h @@ -174,7 +174,7 @@ class KRTextFieldView : public IKRRenderViewExport { void GetTextInputStateInternal(const KRRenderCallback &callback); /** - * 在 OnTextDidChanged 末尾按需触发,参考 Android 时机一致。 + * 在 OnTextDidChanged 中按需触发,且必须早于 legacy textDidChange,与 Android 顺序一致。 * 处于 SetTextInputStateInternal 主动写入期间会被抑制。 */ void NotifyTextInputStateChange(); From 8b546ce28013ee51831ec4bdc03886dad8cb724e Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 19:53:38 +0800 Subject: [PATCH 097/187] fix(compose): blur detached text editors (#25) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../ui/platform/InputFocusTargetReducer.kt | 15 ++++++++--- .../ui/platform/SoftwareKeyboardController.kt | 6 ++++- .../platform/InputFocusTargetReducerTest.kt | 26 +++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt index 29d8aa380..f9ae783da 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt @@ -102,8 +102,6 @@ internal class InputFocusTargetReducer { } internal fun onNativeFocus(view: T, requestId: Long?): NativeFocusDecision { - observedView = view - pendingBlurView = null if (requestId != null) { val matchesCurrentRequest = requestId == generation && @@ -113,6 +111,8 @@ internal class InputFocusTargetReducer { if (!matchesCurrentRequest) { return NativeFocusDecision.IgnoreStale } + observedView = view + pendingBlurView = null pendingFocusView = null pendingFocusGeneration = null focusAttemptCount = 0 @@ -121,6 +121,8 @@ internal class InputFocusTargetReducer { // A native focus event without a request id came from a platform/user focus action. It is // an intent, not authority: Compose FocusOwner still has to accept it. + observedView = view + pendingBlurView = null if (desiredView === view) { pendingFocusView = null pendingFocusGeneration = null @@ -157,7 +159,14 @@ internal class InputFocusTargetReducer { pendingFocusView = null pendingFocusGeneration = null } - if (observedView === view) observedView = null + if (observedView === view) { + // Disposal removes the common callback surface immediately, but the native editor can + // still be first responder until it is explicitly blurred. Clear the observation only + // after emitting that terminal command so a detached/recreated field cannot leave the + // software keyboard visible without a logical focus owner. + commands += Command.Blur(view, generation) + observedView = null + } if (pendingBlurView === view) pendingBlurView = null return commands } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt index e2bcfc993..c0a9c8911 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt @@ -95,7 +95,11 @@ internal class KuiklySoftwareKeyboardController : SoftwareKeyboardController { ): InputFocusTargetReducer.NativeFocusDecision { val decision = focusReducer.onNativeFocus(view, requestId) if (decision == InputFocusTargetReducer.NativeFocusDecision.IgnoreStale) { - scheduleReconcile(view) + // The callback proves that native focus actually landed, even though the request no + // longer belongs to the current generation. Do not publish the detached/old editor as + // observed state; explicitly reject it so native first-responder state cannot survive + // after common ownership moved on. + rejectNativeFocus(view) } return decision } diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt index 7820e1e7f..ef9e75674 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt @@ -200,7 +200,7 @@ class InputFocusTargetReducerTest { } @Test - fun unregisterClearsDetachedDesiredAndObservedView() { + fun unregisterBlursDetachedObservedViewAndClearsFocusState() { val reducer = InputFocusTargetReducer() val view = View("detached") @@ -208,8 +208,30 @@ class InputFocusTargetReducerTest { val request = assertIs>(reducer.reconcile()) reducer.onNativeFocus(view, request.generation) - reducer.unregister(view) + val commands = reducer.unregister(view) + val blur = assertIs>(commands.single()) + assertSame(view, blur.view) + assertEquals(reducer.generation, blur.generation) + assertNull(reducer.desiredView) + assertNull(reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun lateProgrammaticFocusAfterUnregisterCannotReviveDetachedView() { + val reducer = InputFocusTargetReducer() + val view = View("detached-while-focus-queued") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + val commands = reducer.unregister(view) + + assertIs>(commands.single()) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale, + reducer.onNativeFocus(view, request.generation), + ) assertNull(reducer.desiredView) assertNull(reducer.observedView) assertNull(reducer.reconcile()) From bcacb669f361017822fe189048dfc7c31809b3cd Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 20:51:11 +0800 Subject: [PATCH 098/187] sync: absorb Tencent 2.23.1 runtime fixes (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(iOS): gate TextArea auto-focus on programmatic text sync (#1502) Add autoFocusOnTextInputState setProp modifier so syncing non-empty TextFieldValue no longer pops the keyboard by default on iOS; opt in with the modifier when the legacy behavior is needed. Include a compose demo page to reproduce the business InputTextField case. Co-authored-by: Cursor (cherry picked from commit 333d9eea6494e346999aed2452149d56e4fd7e20) Signed-off-by: Cindy * fix(ohos): use NODE_TEXT_AREA_* attrs for TextArea and support real selection range (#1506) KRTextAreaView 继承自 KRTextFieldView 但底层是 ARKUI_NODE_TEXT_AREA,其属性枚举与 ARKUI_NODE_TEXT_INPUT 不同。此前若干路径直接调用 kuikly::util::* 或子类未 override,导致 TextArea 上的 maxLength / returnKeyType / 选区读写落到错配的 TextInput 属性上,行为异常。 本次修复: - 将 EnterKeyType 读取(GetInputNodeEnterKeyType)与 SelectionRange 写入(UpdateInputNodeSelectionRange)虚化到 KRTextFieldView,OnInputReturn/OnWillInsertText/OnPasteText 改走虚函数,KRTextAreaView 用 NODE_TEXT_AREA_ENTER_KEY_TYPE / NODE_TEXT_AREA_TEXT_SELECTION override。 - KRTextAreaView::UpdateInputNodeEnterKeyType 新增 override,写 NODE_TEXT_AREA_ENTER_KEY_TYPE。 - 修复 KRTextAreaView::UpdateInputNodeMaxLength 属性名 typo:NODE_TEXT_INPUT_MAX_LENGTH → NODE_TEXT_AREA_MAX_LENGTH。 - SetTextInputStateInternal 不再降级为折叠光标:TextInput/TextArea 均支持 [start, end] 真实区间选区,selection_end 参与 clamp 后与 selection_start 一起写入。 (cherry picked from commit 55dda7048c949b75a1e39926f796a60a3649c9c1) Signed-off-by: Cindy * fix(ohos): use weak_ptr in animation callbacks to prevent UAF crash (#1509) Replace raw this pointer capture and KRAnimationUserData strong-ref pattern with weak_ptr to prevent Use-After-Free when KRScrollerView is destroyed before animation completion callback fires. Changes: - KRAnimation.h: Remove KRAnimationUserData class, use heap-allocated weak_ptr in SetCompleteCallback for safe invoke - KRScrollerView.cpp: Capture weak_ptr instead of raw this in both animation update and completion lambdas (cherry picked from commit c7d89939c71c71f0b59ddf973810b46b3d5a10c1) Signed-off-by: Cindy * perf(ohos): optimize bridge call performance and add stress test page (#1512) * perf(ohos): optimize bridge call performance and add stress test page - core-ksp: optimize OhOs target entry builder logic - core: optimize TypeUtils, ohos.def and KRRenderCValue type definitions - core-render-ohos: optimize NativeContextHandlerManager, RenderCore, RenderValue, RenderLayerHandler, RenderManager implementations - demo: add standalone BridgeCallStressTestPage for 100K bridge call stress testing, extracted from ButtonExamplePage, and register it in ExampleIndexPage * refactor(context): 明确 OnCallNative 中 arg0 为保留位并优化 cv0 构造 - DispatchCallNative 中 cv0 直接使用 KRRenderValue::MakeNull() 单例,避免每次调用都构造 std::string 与分配 shared_ptr。 - 在 ICallNativeCallback::OnCallNative 与 IKRRenderNativeContextHandler::OnCallNative 两处显式加接口契约注释,声明 arg0 为保留位,实现方不得依赖其内容;历史上曾用于携带 instanceId,如需请通过 handler 自身的 instance_id_ 获取。 - 在 DispatchCallNative 的 cv0 构造处加双向引用注释,形成契约锚点,防止未来单侧修改造成静默 null-deref / 逻辑偏差。 * fix(context): 修复 DispatchCallNative 中 KRRenderCValue 未初始化导致的 UB KRRenderCValue 是聚合类型,其 union value 首成员为 int32_t 且无初始化器,size 也无默认值。原代码 `KRRenderCValue null_cv; null_cv.type = NULL_VALUE;` 只覆盖了 type 字段,value 与 size 仍是栈上残留字节;随后 return 触发结构体值拷贝会 memcpy 未初始化字节,越 napi C ABI 传给 Kotlin 侧属于未定义行为(MSan/UBSan 必报)。 修复:改用 `return KRRenderCValue{};` 值初始化,将 type/value/size 全部归零。涉及两处: - handler 缺失分支(实例已销毁但 Kotlin 侧仍回调); - handler 返回 null 分支(异步方法的热路径,几乎必走)。 同时在两处补充详细契约注释,防止后续再改回 `KRRenderCValue null_cv;` 这种看似更明确、实则埋雷的写法。 * docs(KRRenderValue): 补充 Make(const char*) 特化签名的契约注释 说明 const char*&& 是主模板 Make(Args&&... args) 在 Args = const char* 时的实例化形式,模板特化签名必须与主模板精确匹配。若改回 const char*,该特化不会被主模板匹配到,Make("") 的空字符串单例复用优化将静默失效。 (cherry picked from commit ebe9840a94ff5ff7ab5f30f2fc73ee41389050bb) Signed-off-by: Cindy * fix(ohos): clear sent_start_event in HandleClearSelection (#1515) (cherry picked from commit 15185ff9e1e2588fac76da825aa8c43d4f073b35) Signed-off-by: Cindy * refactor(ohos): remove RunWithFatalGuard to preserve K/N unhandled-exception hook (#1516) Root cause: any C++ catch on the path between the throwing frame and the Kotlin/Native runtime (even 'catch -> log -> rethrow') causes K/N to observe the exception as 'already handled by C++'. As a result the K/N unhandled-exception hook is not fired, and we lose the Kotlin-side Throwable class, message and Kotlin stack trace, which are the most valuable diagnostic information at a JS/K bridge crash. Trade-off: give up the extra C++-side diagnostic line (tag + demangled type + e.what()) that RunWithFatalGuard printed before rethrow, in exchange for the K/N hook firing normally so that the full Kotlin crash context is preserved. Changes: * Delete KRThreadFatalGuard.h (sole implementation). * KRRenderCore.cpp: napi C ABI boundary (com_tencent_kuikly_CallNative) no longer wraps DispatchCallNative in a fatal guard; exception propagates straight into K/N runtime. * KRThread.cpp: OnAsync.batch / DirectRunOnCurThread.nested / DirectRunOnCurThread.borrow all run the task directly; RAII on unique_lock + ExecutingFlagGuard still keeps m_taskMutex / m_isExecutingTask consistent during unwind. * KRMainThread.cpp: MainTimer.cb / MainAsync.batch / Inline.same-thread / Inline.fallback (x2) all run the task directly. * DefaultRenderNativeContextHandler.cpp: drop the catch->log->rethrow wrapper around callKotlin_ for the same reason; the C++-side diagnostic (method_id) is recoverable from the Kotlin stack. * KRContextScheduler.cpp: refresh a comment that referenced the removed guard. * docs/design/krthread-task-mutex.md: sync the design doc with the new fail-forward semantics and record the K/N hook rationale. (cherry picked from commit 3ac66b1cc0d87669c72ae131f9a5e35df2564a01) Signed-off-by: Cindy * fix(ohos): align CreateSelection event dispatch with Android semantics (#1517) - Add from_user flag to CreateSelection/CalculateHandleFramesAndDoUpdate - Business createSelection reusing active session with changed rect -> selectStart - Business createSelection reusing active session with same rect -> no event - Business createSelection hitting empty while active -> selectCancel - Drag handle update -> selectChange (unchanged) (cherry picked from commit 38b3bbe4ef9d342a59fda32b26de0217e8dfd2fe) Signed-off-by: Cindy * chore(ohos): bump compose runtime deps to 1.7.3-kuikly2 (#1525) Upgrade kuikly-open compose runtime artifacts for OHOS builds. Co-authored-by: Cursor (cherry picked from commit 388f639bcf79a3e675ed33eb005b3a594dedf243) Signed-off-by: Cindy * fix(android): prevent KRNotifyModule from re-registering receiver after onDestroy (#1522) Co-authored-by: KuiklyAI (cherry picked from commit 72f3833d5c3c86b9527c21ac7028836441f434b3) Signed-off-by: Cindy * fix(android): no need to stop animate when Pager dragEnd is sync (#1527) Co-authored-by: zenipchen (cherry picked from commit 8adcaa92840fa088d6c9ab85af4c779fc4f464cd) Signed-off-by: Cindy * fix(ohos): use default pager spring for damping one (#1528) Co-authored-by: Cursor (cherry picked from commit 1048ad0189ee4579bd9585d00b9623e3d4d6a125) Signed-off-by: Cindy * fix(ios): arbitrate text-state autofocus intent Signed-off-by: Cindy * style(ohos): remove upstream trailing whitespace Signed-off-by: Cindy * fix(ios): separate autofocus intent from observed focus Signed-off-by: Cindy * fix(ios): ignore detached autofocus intents Signed-off-by: Cindy --------- Signed-off-by: Cindy Co-authored-by: luoyibu Co-authored-by: Cursor Co-authored-by: ruifanyuan <144208229+ruifanyuan@users.noreply.github.com> Co-authored-by: iPel Co-authored-by: KuiklyAI Co-authored-by: KuiklyAI Co-authored-by: zenipchen <870658410@qq.com> Co-authored-by: zenipchen Co-authored-by: Cindy --- compose/build.2.0.ohos.gradle.kts | 8 +- .../compose/extension/ModifierSetProp.kt | 9 + .../compose/foundation/text/CoreTextField.kt | 19 +- .../ui/platform/InputFocusTargetReducer.kt | 14 + .../ui/platform/SoftwareKeyboardController.kt | 5 + .../text/FocusRequesterLifecycleTest.kt | 12 + .../platform/InputFocusTargetReducerTest.kt | 24 ++ .../kotlin/impl/OhOsTargetEntryBuilder.kt | 29 +- .../impl/OhOsTargetMultiEntryBuilder.kt | 29 +- .../expand/component/list/KRRecyclerView.kt | 6 +- .../android/expand/module/KRNotifyModule.kt | 3 + .../Extension/Components/KRTextAreaView.m | 23 +- .../docs/design/krthread-task-mutex.md | 4 +- .../DefaultRenderNativeContextHandler.cpp | 37 +-- .../context/IKRRenderNativeContextHandler.h | 20 ++ .../KRRenderNativeContextHandlerManager.cpp | 33 ++- .../KRRenderNativeContextHandlerManager.h | 6 +- .../cpp/libohos_render/core/KRRenderCore.cpp | 57 ++-- .../components/input/KRTextAreaView.cpp | 26 +- .../expand/components/input/KRTextAreaView.h | 3 + .../components/input/KRTextFieldView.cpp | 36 ++- .../expand/components/input/KRTextFieldView.h | 18 +- .../components/scroller/KRScrollerView.cpp | 25 +- .../components/scroller/KRScrollerView.h | 1 + .../expand/components/view/KRView.cpp | 26 +- .../expand/components/view/KRView.h | 4 +- .../cpp/libohos_render/foundation/KRRect.h | 3 + .../foundation/thread/KRMainThread.cpp | 33 +-- .../foundation/thread/KRThread.cpp | 31 +- .../foundation/thread/KRThreadFatalGuard.h | 108 ------- .../foundation/type/KRRenderCValue.h | 6 +- .../foundation/type/KRRenderValue.h | 166 +++++++---- .../layer/KRRenderLayerHandler.cpp | 14 +- .../manager/KRRenderManager.cpp | 5 +- .../scheduler/KRContextScheduler.cpp | 4 +- .../cpp/libohos_render/utils/KRViewUtil.cpp | 18 +- .../cpp/libohos_render/utils/KRViewUtil.h | 3 +- .../utils/animate/KRAnimation.h | 24 +- .../tencent/kuikly/core/views/InputView.kt | 9 +- .../tencent/kuikly/core/views/TextAreaView.kt | 8 +- .../tencent/kuikly/core/utils/TypeUtils.kt | 24 +- .../ohosInterop/cinterop/ohos.def | 4 +- .../ohosInterop/include/KRRenderCValue.h | 4 +- .../demo/pages/compose/ComposeAllSample.kt | 1 + .../compose/IosKeyboardInputTextFieldDemo.kt | 272 ++++++++++++++++++ .../pages/demo/catalog/ExampleIndexPage.kt | 7 + 46 files changed, 831 insertions(+), 390 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/FocusRequesterLifecycleTest.kt delete mode 100644 core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThreadFatalGuard.h create mode 100644 demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/IosKeyboardInputTextFieldDemo.kt diff --git a/compose/build.2.0.ohos.gradle.kts b/compose/build.2.0.ohos.gradle.kts index 80b210f13..4b12e34c3 100644 --- a/compose/build.2.0.ohos.gradle.kts +++ b/compose/build.2.0.ohos.gradle.kts @@ -57,10 +57,10 @@ kotlin { } commonMain.dependencies { implementation(project(":core")) - api("com.tencent.kuikly-open.compose.runtime:runtime:1.7.3-kuikly1") - api("com.tencent.kuikly-open.compose.runtime:runtime-saveable:1.7.3-kuikly1") - api("com.tencent.kuikly-open.compose.annotation-internal:annotation:1.7.3-kuikly1") - api("com.tencent.kuikly-open.compose.collection-internal:collection:1.7.3-kuikly1") + api("com.tencent.kuikly-open.compose.runtime:runtime:1.7.3-kuikly2") + api("com.tencent.kuikly-open.compose.runtime:runtime-saveable:1.7.3-kuikly2") + api("com.tencent.kuikly-open.compose.annotation-internal:annotation:1.7.3-kuikly2") + api("com.tencent.kuikly-open.compose.collection-internal:collection:1.7.3-kuikly2") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-KBA-002") api("org.jetbrains.kotlinx:atomicfu:0.23.2-KBA-001") } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/ModifierSetProp.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/ModifierSetProp.kt index 6cec5001f..fcfb71425 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/ModifierSetProp.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/ModifierSetProp.kt @@ -111,3 +111,12 @@ internal fun Modifier.cursor(type: String): Modifier = setProp("cursor", type) * @param processor processor name, e.g. "input" */ fun Modifier.textPostProcessor(processor: String): Modifier = setProp("textPostProcessor", processor) + +/** + * iOS:控制程序化同步 [com.tencent.kuikly.compose.ui.text.input.TextFieldValue](原生 setTextInputState)时, + * 非空文本是否自动抢占焦点并弹起键盘。 + * + * 默认不设置时为 false,避免进页带预填文本时自动弹键盘;需要旧行为时显式传 true。 + */ +fun Modifier.autoFocusOnTextInputState(enabled: Boolean): Modifier = + setProp("autoFocusOnTextInputState", if (enabled) 1 else 0) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt index ccfa65c4c..cd8fa6b3f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt @@ -375,6 +375,20 @@ internal fun CoreTextField( getViewAttr().autofocus(false) getViewAttr().enablePinyinCallback(true) getViewEvent().inputFocus { params -> + if (params.focusIntentOnly) { + if (!enabled || readOnly) { + return@inputFocus + } + val intentDecision = + kuiklyKeyboardController?.onNativeFocusIntent(autoHeightTextAreaView) + if ( + intentDecision == InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus || + intentDecision == null + ) { + focusRequester.focusIfAttached() + } + return@inputFocus + } val nativeFocusDecision = kuiklyKeyboardController?.onNativeFocus( autoHeightTextAreaView, params.focusRequestId, @@ -390,7 +404,7 @@ internal fun CoreTextField( // first responder only after FocusOwner commits the request. // requestFocus() returns Unit, so it cannot close captured / // disabled / lifecycle rejection races. - if (!focusRequester.hasAttachedNodes() || !focusRequester.focus()) { + if (!focusRequester.focusIfAttached()) { kuiklyKeyboardController?.rejectNativeFocus(autoHeightTextAreaView) } } @@ -641,6 +655,9 @@ internal fun CoreTextField( } } +internal fun FocusRequester.focusIfAttached(): Boolean = + hasAttachedNodes() && focus() + internal class TextInputSyncRevisionTracker { private var latestIssuedRevision: Int = 0 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt index f9ae783da..c734daba9 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt @@ -132,6 +132,20 @@ internal class InputFocusTargetReducer { return NativeFocusDecision.RequestComposeFocus } + /** + * Handles a native request to acquire Compose focus before native focus has landed. + * + * Unlike [onNativeFocus], this must not update [observedView]. Compose FocusOwner approval + * calls start(), then reconcile() emits the generation-scoped native focus command. Only the + * later native focus callback may confirm observed state. + */ + internal fun onNativeFocusIntent(view: T): NativeFocusDecision = + if (desiredView === view) { + NativeFocusDecision.Confirmed + } else { + NativeFocusDecision.RequestComposeFocus + } + internal fun onNativeBlur(view: T, requestId: Long?): NativeBlurDecision { val shouldClearComposeFocus = requestId == null && desiredView === view if (observedView === view) observedView = null diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt index c0a9c8911..bf17c6880 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/SoftwareKeyboardController.kt @@ -104,6 +104,11 @@ internal class KuiklySoftwareKeyboardController : SoftwareKeyboardController { return decision } + internal fun onNativeFocusIntent( + view: AutoHeightTextAreaView, + ): InputFocusTargetReducer.NativeFocusDecision = + focusReducer.onNativeFocusIntent(view) + internal fun onNativeBlur( view: AutoHeightTextAreaView, requestId: Long?, diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/FocusRequesterLifecycleTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/FocusRequesterLifecycleTest.kt new file mode 100644 index 000000000..a984ec576 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/FocusRequesterLifecycleTest.kt @@ -0,0 +1,12 @@ +package com.tencent.kuikly.compose.foundation.text + +import com.tencent.kuikly.compose.ui.focus.FocusRequester +import kotlin.test.Test +import kotlin.test.assertFalse + +class FocusRequesterLifecycleTest { + @Test + fun detachedRequesterSilentlyRejectsLateNativeFocusIntent() { + assertFalse(FocusRequester().focusIfAttached()) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt index ef9e75674..4a64304bc 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt @@ -152,6 +152,30 @@ class InputFocusTargetReducerTest { assertNull(reducer.reconcile()) } + @Test + fun programmaticFocusIntentWaitsForGenerationFocusBeforeBecomingObserved() { + val reducer = InputFocusTargetReducer() + val view = View("autofocus-intent") + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + reducer.onNativeFocusIntent(view), + ) + assertNull(reducer.observedView) + assertNull(reducer.desiredView) + + reducer.start(view) + val focus = assertIs>(reducer.reconcile()) + assertSame(view, focus.view) + assertNull(reducer.observedView) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, focus.generation), + ) + assertSame(view, reducer.observedView) + } + @Test fun rejectedNativeUserFocusDoesNotBecomeObservedAuthority() { val reducer = InputFocusTargetReducer() diff --git a/core-ksp/src/main/kotlin/impl/OhOsTargetEntryBuilder.kt b/core-ksp/src/main/kotlin/impl/OhOsTargetEntryBuilder.kt index b2caef0c4..a5052cf94 100644 --- a/core-ksp/src/main/kotlin/impl/OhOsTargetEntryBuilder.kt +++ b/core-ksp/src/main/kotlin/impl/OhOsTargetEntryBuilder.kt @@ -31,6 +31,8 @@ class OhOsTargetEntryBuilder(private val catchException: Boolean) : KuiklyCoreAb builder.addImport("com.tencent.kuikly.core.exception", "ExceptionTracker") addImport("kotlinx.cinterop", "memScoped") addImport("kotlinx.cinterop", "invoke") + addImport("kotlinx.cinterop", "alloc") + addImport("kotlinx.cinterop", "ptr") addImport("com.tencent.kuikly.core.utils", "asString") addImport("com.tencent.kuikly.core.manager", "KotlinMethod") addImport("kotlinx.cinterop", "staticCFunction") @@ -128,16 +130,25 @@ class OhOsTargetEntryBuilder(private val catchException: Boolean) : KuiklyCoreAb .addCode( """ |return memScoped { - | val cValue = ohos.com_tencent_kuikly_CallNative( + | // 优化:直接在 arena 上 alloc + 填充,避免 cValue 产生的中间 ByteArray + | val cv0 = alloc(); arg0.%T(this, cv0) + | val cv1 = alloc(); arg1.%T(this, cv1) + | val cv2 = alloc(); arg2.%T(this, cv2) + | val cv3 = alloc(); arg3.%T(this, cv3) + | val cv4 = alloc(); arg4.%T(this, cv4) + | val cv5 = alloc(); arg5.%T(this, cv5) + | val result = alloc() + | ohos.com_tencent_kuikly_CallNative( | methodId, - | arg0.%T(this), - | arg1.%T(this), - | arg2.%T(this), - | arg3.%T(this), - | arg4.%T(this), - | arg5.%T(this) - | )?.%T() - | cValue + | cv0.ptr, + | cv1.ptr, + | cv2.ptr, + | cv3.ptr, + | cv4.ptr, + | cv5.ptr, + | result.ptr + | ) + | result.%T() |} """.trimMargin(), toKRRenderCValue, diff --git a/core-ksp/src/main/kotlin/impl/OhOsTargetMultiEntryBuilder.kt b/core-ksp/src/main/kotlin/impl/OhOsTargetMultiEntryBuilder.kt index 9d1952e75..63c745d91 100644 --- a/core-ksp/src/main/kotlin/impl/OhOsTargetMultiEntryBuilder.kt +++ b/core-ksp/src/main/kotlin/impl/OhOsTargetMultiEntryBuilder.kt @@ -34,6 +34,8 @@ class OhOsTargetMultiEntryBuilder(private val catchException: Boolean, private v builder.addImport("com.tencent.kuikly.core.exception", "ExceptionTracker") addImport("kotlinx.cinterop", "memScoped") addImport("kotlinx.cinterop", "invoke") + addImport("kotlinx.cinterop", "alloc") + addImport("kotlinx.cinterop", "ptr") addImport("com.tencent.kuikly.core.utils", "asString") addImport("com.tencent.kuikly.core.manager", "KotlinMethod") addImport("kotlinx.cinterop", "staticCFunction") @@ -151,16 +153,25 @@ class OhOsTargetMultiEntryBuilder(private val catchException: Boolean, private v .addCode( """ |return memScoped { - | val cValue = ohos.com_tencent_kuikly_CallNative( + | // 优化:直接在 arena 上 alloc + 填充,避免 cValue 产生的中间 ByteArray + | val cv0 = alloc(); arg0.%T(this, cv0) + | val cv1 = alloc(); arg1.%T(this, cv1) + | val cv2 = alloc(); arg2.%T(this, cv2) + | val cv3 = alloc(); arg3.%T(this, cv3) + | val cv4 = alloc(); arg4.%T(this, cv4) + | val cv5 = alloc(); arg5.%T(this, cv5) + | val result = alloc() + | ohos.com_tencent_kuikly_CallNative( | methodId, - | arg0.%T(this), - | arg1.%T(this), - | arg2.%T(this), - | arg3.%T(this), - | arg4.%T(this), - | arg5.%T(this) - | )?.%T() - | cValue + | cv0.ptr, + | cv1.ptr, + | cv2.ptr, + | cv3.ptr, + | cv4.ptr, + | cv5.ptr, + | result.ptr + | ) + | result.%T() |} """.trimMargin(), toKRRenderCValue, diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt index be3801e86..6e252b250 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/list/KRRecyclerView.kt @@ -704,8 +704,10 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi // 导致 RV 内部的状态一直都 DRAGGING,因此在 onInterceptEvent的时候,RV 内部一直拦截事件 // 导致 RV 内部的横向子 List 无法滑动 // 触发条件:先在横向子 List 滑动然后触发 cancel - scrollAnimationManager.cancel() - stopScroll() + // When Pager dragEnd is sync, there is no need to stop the animation, + // otherwise the scroll animation is unexpectedly interrupted. +// scrollAnimationManager.cancel() +// stopScroll() return true } return super.fling(adjustedVelocityX, adjustedVelocityY) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRNotifyModule.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRNotifyModule.kt index 03324c456..e5660ffa4 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRNotifyModule.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRNotifyModule.kt @@ -35,6 +35,7 @@ open class KRNotifyModule : KuiklyRenderBaseModule() { private val toHRMap: MutableMap> = mutableMapOf() private var notifyBroadcastReceiver: HRNotifyModuleReceiver? = null + private var isDestroyed = false override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? { return when (method) { @@ -47,6 +48,7 @@ open class KRNotifyModule : KuiklyRenderBaseModule() { override fun onDestroy() { super.onDestroy() + isDestroyed = true unregisterNotifyModuleReceiver() } @@ -112,6 +114,7 @@ open class KRNotifyModule : KuiklyRenderBaseModule() { } protected open fun registerNotifyModuleReceiver(event: String, params: JSONObject) { + if (isDestroyed) return if (notifyBroadcastReceiver == null) { notifyBroadcastReceiver = HRNotifyModuleReceiver { val eventName = it.getStringExtra(KEY_EVENT_NAME) ?: "" diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index 9396cddeb..1d98756dc 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -95,6 +95,8 @@ @interface KRTextAreaView() @property (nonatomic, strong) KuiklyRenderCallback KUIKLY_PROP(selectionChange); /** attr is textInputState */ @property (nonatomic, strong) NSString *KUIKLY_PROP(textInputState); +/** attr is autoFocusOnTextInputState 程序化同步 textInputState 时,非空文本是否自动聚焦 */ +@property (nonatomic, strong) NSNumber *KUIKLY_PROP(autoFocusOnTextInputState); /** placeholderTextView property */ @property (nullable, nonatomic, strong) UITextView *placeholderTextView; @@ -136,6 +138,7 @@ - (instancetype)init { if (self = [super init]) { self.delegate = self; self.css_autoHideKeyboardOnImeAction = [NSNumber numberWithInt: 1]; // 保持原有能力,默认是关闭关闭软键盘 + self.css_autoFocusOnTextInputState = @0; #if TARGET_OS_OSX // [macOS] self.textContainerInset = NSZeroSize; // macOS: 启用 layer-backed 支持 clipPath @@ -461,9 +464,8 @@ - (void)css_setTextInputState:(NSDictionary *)args { NSInteger selectionStart = MAX(0, MIN(requestedSelectionStart, (NSInteger)rawText.length)); NSInteger selectionEnd = MAX(0, MIN(requestedSelectionEnd, (NSInteger)rawText.length)); - if (![self isFirstResponder] && rawText.length > 0) { - [self becomeFirstResponder]; - } + BOOL shouldRequestComposeFocus = + ![self isFirstResponder] && rawText.length > 0 && [self.css_autoFocusOnTextInputState boolValue]; _ignoreTextDidChanged = YES; NSString *currentRawText = [self p_outputText]; BOOL textChanged = ![currentRawText isEqualToString:rawText]; @@ -508,6 +510,21 @@ - (void)css_setTextInputState:(NSDictionary *)args { @"length": @([self p_calculateLengthForText:outputText]) }); } + if (shouldRequestComposeFocus && self.css_inputFocus) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.isFirstResponder || ![self.css_autoFocusOnTextInputState boolValue] || !self.css_inputFocus) { + return; + } + // Programmatic auto-focus is an intent, not native authority. Route + // it through the same request-id/generation arbiter as a user focus + // event so Compose FocusOwner can accept or reject it before the + // editor becomes first responder. + self.css_inputFocus(@{ + @"text" : [self p_outputText] ?: @"", + @"focusIntentOnly" : @YES + }); + }); + } } - (void)css_getTextInputState:(NSDictionary *)args { diff --git a/core-render-ohos/docs/design/krthread-task-mutex.md b/core-render-ohos/docs/design/krthread-task-mutex.md index d4d18782a..bf7ec7b2e 100644 --- a/core-render-ohos/docs/design/krthread-task-mutex.md +++ b/core-render-ohos/docs/design/krthread-task-mutex.md @@ -238,14 +238,14 @@ Release build 零成本(NDEBUG 编译期消除),Debug build 一旦短路 | 1 | `DirectRunOnCurThread` 全仓唯一调用点 | [`KRContextScheduler.cpp` L76 `GetContextThread()->DirectRunOnCurThread(...)`](../../src/main/cpp/libohos_render/scheduler/KRContextScheduler.cpp) | | 2 | `m_stop` 唯一写入点是 `~KRThread()` | [`KRThread.cpp` L62 `m_stop.store(true)`](../../src/main/cpp/libohos_render/foundation/thread/KRThread.cpp),紧随其后即 `m_workerThread.join()` | | 3 | worker 唯一持有 `m_taskMutex` 的地方是 `OnAsync` 阶段 2 的 `lock_guard` | [`KRThread.cpp` L179 附近](../../src/main/cpp/libohos_render/foundation/thread/KRThread.cpp) | -| 4 | task 体抛异常走 fail-forward 语义:catch → 日志 → rethrow → `std::terminate` → `abort` | 沿途每层 [`RunWithFatalGuard`](../../src/main/cpp/libohos_render/foundation/thread/KRThreadFatalGuard.h) 打完完整诊断日志(tag + demangled 类型 + `e.what()`)后 `throw;` 继续 unwind,给 K/N runtime 的 unhandled-exception hook 留出触发窗口后再终止进程 | +| 4 | task 体抛异常直接冒到 `std::terminate` → `abort`,中途不做 C++ catch | 曾经沿途每层套 `RunWithFatalGuard`(catch → 日志 → rethrow)力求补一层诊断,但实测 K/N runtime 会因为观察到 "C++ 已 catch" 而不再触发 unhandled-exception hook,反而丢失 Kotlin 侧 Throwable class / message / Kotlin 栈。故本仓改为**放弃 C++ 侧的补充诊断日志**,让异常裸露给 K/N runtime 以保 hook 触发窗口 | #### 4.2.2 前提场景在当前代码里构造不出来 **建议里的前提之一:**"worker 已退出但 `m_taskMutex` 未被释放(worker 在析构前异常退出未 unlock)" - `m_taskMutex` 全部走 RAII `lock_guard` / `unique_lock`,正常返回路径必然释放; -- 异常路径:`DirectRunOnCurThread` 借位分支的 `unique_lock` + `ExecutingFlagGuard` 都是 RAII,`RunWithFatalGuard` 把异常 `throw;` 向外 unwind 时 mutex 与标志位都会自动回到干净状态;异常最终抛到 `std::terminate` → `abort` 终止进程——进程都终止了,讨论"锁是否释放"毫无意义; +- 异常路径:`DirectRunOnCurThread` 借位分支的 `unique_lock` + `ExecutingFlagGuard` 都是 RAII,异常 unwind 时 mutex 与标志位都会自动回到干净状态;异常不被任何中间 C++ catch 拦截,直接冒到 K/N unhandled hook / `std::terminate` → `abort` 终止进程——进程都终止了,讨论"锁是否释放"毫无意义; - 结论:**"worker 未释放锁就退出"** 在当前代码里构造不出来。 **建议里的前提之二:**"`KRThread` 正在析构(`m_stop=true`)...`DirectRunOnCurThread` 会空转" diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/DefaultRenderNativeContextHandler.cpp b/core-render-ohos/src/main/cpp/libohos_render/context/DefaultRenderNativeContextHandler.cpp index 4a4bc19b7..3948bdb3b 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/DefaultRenderNativeContextHandler.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/context/DefaultRenderNativeContextHandler.cpp @@ -14,9 +14,7 @@ */ #include -#include #include "DefaultRenderNativeContextHandler.h" -#include "libohos_render/foundation/thread/KRThreadFatalGuard.h" #include "libohos_render/utils/KRRenderLoger.h" extern CallKotlin callKotlin_; @@ -31,32 +29,11 @@ void DefaultRenderNativeContextHandler::CallKotlinMethod(const KuiklyRenderConte if (callKotlin_ == nullptr) { __assert_fail("Tips: make sure initKuikly() has been called!", __FILE__, __LINE__, __func__); } - // Diagnostics-only wrapper around the Kotlin/Native call boundary. - // - // 语义:与 KRThreadFatalGuard 一致的 fail-forward - // * 在 catch 里补一条"哪个 method_id 抛的"诊断(这条信息在外层 fatal guard - // 里拿不到,故必须就近记录); - // * 立即 `throw;` 让原始异常继续 unwind: - // - 保留 K/N runtime 的 unhandled-exception hook 触发窗口(hook 挂在 - // std::terminate 路径上,会先于最终 abort 打出完整 Kotlin 栈); - // - 上层 KRThread::DirectRunOnCurThread.{nested,borrow} 的 - // RunWithFatalGuard 会再打一层 tag + demangled 类型 + e.what() 后 rethrow, - // 最终 std::terminate → abort。 - // * 类型名 demangle 委托给 kuikly::thread::CurrentExceptionTypeName, - // 全仓单实现,避免遗漏 K/N 非 std::exception 派生类型。 - const int method_id = static_cast(method); - try { - callKotlin_(method_id, arg0->toCValue(), arg1->toCValue(), arg2->toCValue(), arg3->toCValue(), - arg4->toCValue(), arg5->toCValue()); - } catch (const std::exception &e) { - KR_LOG_ERROR_WITH_TAG("KRRender") - << "[callKotlin_] std::exception at K/N boundary; method=" << method_id - << " type=" << kuikly::thread::CurrentExceptionTypeName() << " what=" << e.what(); - throw; - } catch (...) { - KR_LOG_ERROR_WITH_TAG("KRRender") - << "[callKotlin_] non-std exception at K/N boundary; method=" << method_id - << " type=" << kuikly::thread::CurrentExceptionTypeName(); - throw; - } + // K/N 调用边界:不套任何 C++ catch,让异常原样冒到 K/N runtime。 + // 曾经在此处 catch → 补一条 "哪个 method_id 抛的" 诊断日志 → rethrow, + // 但实测 K/N 会因为观察到 "C++ 已 catch 过" 而不再触发 unhandled-exception hook, + // 导致丢失 Kotlin 侧真正有价值的 Throwable class / message / Kotlin 栈。 + // 为保留 hook 触发窗口,放弃 C++ 侧的补充诊断日志(method_id 可在 Kotlin 栈中反查)。 + callKotlin_(static_cast(method), arg0->toCValue(), arg1->toCValue(), arg2->toCValue(), arg3->toCValue(), + arg4->toCValue(), arg5->toCValue()); } diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h b/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h index 688aca3ac..ad9fa671f 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h +++ b/core-render-ohos/src/main/cpp/libohos_render/context/IKRRenderNativeContextHandler.h @@ -80,6 +80,24 @@ using KRRenderContextHandlerCreator = class ICallNativeCallback { public: ICallNativeCallback() {} + /** + * 处理来自 Kotlin 侧的 Native 方法调用。 + * + * 契约说明: + * - arg0 为 **保留位(reserved slot)**,实现方不得依赖其内容。 + * 历史上该参数曾用于携带 instanceId,但当前调度层 + * (KRRenderNativeContextHandlerManager::DispatchCallNative) + * 出于性能考量固定传入 KRRenderValue::MakeNull() 单例, + * 以避免每次调用都构造一个 std::string 并分配 shared_ptr。 + * 如实现方需要 instanceId,请通过 handler 自身持有的 + * `IKRRenderNativeContextHandler::instance_id_` 获取。 + * - arg1..arg5 的语义由 KuiklyRenderNativeMethod 各枚举值决定, + * 具体参见 KRRenderCore::PerformNativeCallback 的分派实现。 + * + * 如未来需要恢复通过 arg0 传递 instanceId,请同步修改 + * KRRenderNativeContextHandlerManager::DispatchCallNative 的构造逻辑, + * 否则会形成静默的 null-deref / 逻辑偏差。 + */ virtual std::shared_ptr OnCallNative(const KuiklyRenderNativeMethod &method, std::shared_ptr &arg0, std::shared_ptr &arg1, std::shared_ptr &arg2, @@ -103,6 +121,8 @@ class IKRRenderNativeContextHandler : public std::enable_shared_from_this OnCallNative(const KuiklyRenderNativeMethod &method, std::shared_ptr &arg0, std::shared_ptr &arg1, std::shared_ptr &arg2, diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp index da6150c97..dda437ad0 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.cpp @@ -84,15 +84,24 @@ void KRRenderNativeContextHandlerManager::ScheduleDeallocRenderValues( } } +static inline std::shared_ptr MakeFromCValue(const KRRenderCValue &cValue) { + if (cValue.type == KRRenderCValue::NULL_VALUE) { + return KRRenderValue::MakeNull(); // 复用静态单例,避免堆分配 + } + return KRRenderValue::Make(cValue); +} + KRRenderCValue KRRenderNativeContextHandlerManager::DispatchCallNative( const std::string &instanceId, int methodId, const KRRenderCValue &arg0, const KRRenderCValue &arg1, const KRRenderCValue &arg2, const KRRenderCValue &arg3, const KRRenderCValue &arg4, const KRRenderCValue &arg5) { - auto cv0 = KRRenderValue::Make(arg0); - auto cv1 = KRRenderValue::Make(arg1); - auto cv2 = KRRenderValue::Make(arg2); - auto cv3 = KRRenderValue::Make(arg3); - auto cv4 = KRRenderValue::Make(arg4); - auto cv5 = KRRenderValue::Make(arg5); + // arg0 is a reserved slot. Keep the task #26 off-context marshal contract, + // but use the upstream null singleton and NULL fast path for every value. + auto cv0 = KRRenderValue::MakeNull(); + auto cv1 = MakeFromCValue(arg1); + auto cv2 = MakeFromCValue(arg2); + auto cv3 = MakeFromCValue(arg3); + auto cv4 = MakeFromCValue(arg4); + auto cv5 = MakeFromCValue(arg5); auto method = static_cast(methodId); if (!KRContextScheduler::IsCurrentOnContextThread()) { if (KRNativeMethodRequiresContextThread(method, cv5)) { @@ -103,16 +112,14 @@ KRRenderCValue KRRenderNativeContextHandlerManager::DispatchCallNative( KRContextScheduler::ScheduleTask(0, [this, instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5]() mutable { DispatchPreparedCallNative(instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5); }); - KRRenderCValue null_return_value; - null_return_value.type = KRRenderCValue::NULL_VALUE; - return null_return_value; + return KRRenderCValue{}; } auto return_value = DispatchPreparedCallNative(instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5); - if (return_value == nullptr) { - KRRenderCValue null_return_value; - null_return_value.type = KRRenderCValue::NULL_VALUE; - return null_return_value; + if (return_value == nullptr || return_value->isNull()) { + // Value-initialize the aggregate so union value and size never leak + // uninitialized stack bytes across the napi C ABI. + return KRRenderCValue{}; } ScheduleDeallocRenderValues(return_value); return return_value->toCValue(); diff --git a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h index 49ca36359..237ed3f1e 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h +++ b/core-render-ohos/src/main/cpp/libohos_render/context/KRRenderNativeContextHandlerManager.h @@ -27,11 +27,11 @@ template class KRThreadSafeMap{ public: - void Set(KeyType key, ValueType value){ + void Set(const KeyType &key, ValueType value){ KRScopedSpinLock lock(&lock_); map_[key] = value; } - ValueType Get(KeyType key){ + ValueType Get(const KeyType &key){ { KRScopedSpinLock lock(&lock_); if(auto it = map_.find(key); it != map_.end()){ @@ -41,7 +41,7 @@ class KRThreadSafeMap{ return ValueType(); } - void Erase(KeyType key){ + void Erase(const KeyType &key){ KRScopedSpinLock lock(&lock_); map_.erase(key); } diff --git a/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp b/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp index 638fecf71..a7663826e 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/core/KRRenderCore.cpp @@ -19,7 +19,6 @@ #include #include #include "libohos_render/foundation/KRRect.h" -#include "libohos_render/foundation/thread/KRThreadFatalGuard.h" #include "libohos_render/layer/KRRenderLayerHandler.h" #include "libohos_render/manager/KRArkTSManager.h" #include "libohos_render/scheduler/KRContextScheduler.h" @@ -28,19 +27,16 @@ #include "libohos_render/manager/KRRenderManager.h" EXTERN_C_START -const KRRenderCValue com_tencent_kuikly_CallNative(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, - KRRenderCValue arg2, KRRenderCValue arg3, KRRenderCValue arg4, - KRRenderCValue arg5) { - // napi C ABI 边界:与 KRThread / KRMainThread 调度边界同口径, - // 任何 C++ 异常逃到 C ABI 都会越 napi 调度帧造成 UB,必须 fail-fast。 - // 用 RunWithFatalGuard 替代裸 try-catch,让"打 log + abort" 的行为 - // 集中到唯一一处实现,避免遗漏 e.what()。 - KRRenderCValue result{.type = KRRenderCValue::Type::NULL_VALUE}; - kuikly::thread::RunWithFatalGuard("KRRenderCore.ABI.CallNative", [&] { - result = IKRRenderNativeContextHandler::DispatchCallNative(std::string(arg0.value.stringValue), methodId, arg0, - arg1, arg2, arg3, arg4, arg5); - }); - return result; +void com_tencent_kuikly_CallNative(int methodId, const KRRenderCValue *arg0, const KRRenderCValue *arg1, + const KRRenderCValue *arg2, const KRRenderCValue *arg3, const KRRenderCValue *arg4, + const KRRenderCValue *arg5, KRRenderCValue *result) { + // napi C ABI 边界:不再套 C++ catch,让异常原样冒到 K/N runtime。 + // 曾经在此处 catch → log → rethrow,虽然保留了 std::current_exception(), + // 但 K/N 会因为观察到 "C++ 已 catch 过" 而不再触发 unhandled-exception hook, + // 从而丢失 Kotlin 侧真正有价值的 Throwable class / message / Kotlin 栈。 + // 现在完全放弃 C++ 侧的诊断日志(tag/type/what),换取 K/N hook 的正常触发。 + *result = IKRRenderNativeContextHandler::DispatchCallNative(std::string(arg0->value.stringValue), methodId, + *arg0, *arg1, *arg2, *arg3, *arg4, *arg5); } CallKotlin callKotlin_; @@ -327,23 +323,26 @@ KRAnyValue KRRenderCore::PerformNativeCallback(const KuiklyRenderNativeMethod &m break; } case KuiklyRenderNativeMethod::KuiklyRenderNativeMethodCallModuleMethod: { - auto callbackId = arg4->toString(); KRRenderCallback callback = nullptr; auto callback_keep_alive = false; - if (!callbackId.empty()) { - callback_keep_alive = IsCallbackKeepAlive(arg5); - std::weak_ptr weakSelf = shared_from_this(); - callback = [weakSelf, arg4](KRAnyValue res) { - if (auto locked = weakSelf.lock()) { - PerformTaskOnContextQueue(0, [weakSelf, arg4, res] { - if (auto locked = weakSelf.lock()) { - locked->CallKotlinMethod(KuiklyRenderContextMethod::KuiklyRenderContextMethodFireCallback, arg4, - res, locked->defaultNullValue_, locked->defaultNullValue_, - locked->defaultNullValue_); - } - }); - } - }; + // 优化:先检查 arg4 是否为 null,避免不必要的 toString() 字符串拷贝 + if (!arg4->isNull()) { + auto callbackId = arg4->toString(); + if (!callbackId.empty()) { + callback_keep_alive = IsCallbackKeepAlive(arg5); + std::weak_ptr weakSelf = shared_from_this(); + callback = [weakSelf, arg4](KRAnyValue res) { + if (auto locked = weakSelf.lock()) { + PerformTaskOnContextQueue(0, [weakSelf, arg4, res] { + if (auto locked = weakSelf.lock()) { + locked->CallKotlinMethod(KuiklyRenderContextMethod::KuiklyRenderContextMethodFireCallback, arg4, + res, locked->defaultNullValue_, locked->defaultNullValue_, + locked->defaultNullValue_); + } + }); + } + }; + } } return renderLayerHandler_->CallModuleMethod(sync, arg1->toString(), arg2->toString(), arg3, callback, callback_keep_alive); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp index 1025962ce..4ea5874cb 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.cpp @@ -88,10 +88,27 @@ void KRTextAreaView::UpdateInputNodeKeyboardType(const std::string &propValue) { kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_TYPE, &item); } +void KRTextAreaView::UpdateInputNodeEnterKeyType(const std::string &propValue) { + // KRTextAreaView 底层是 ARKUI_NODE_TEXT_AREA,需写 NODE_TEXT_AREA_ENTER_KEY_TYPE, + // 否则 returnKeyType 不生效或写入到 TextInput 属性上。 + ArkUI_NumberValue value[] = {{.i32 = kuikly::util::ConvertToEnterKeyType(propValue)}}; + ArkUI_AttributeItem item = {value, sizeof(value) / sizeof(ArkUI_NumberValue)}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_ENTER_KEY_TYPE, &item); +} + +ArkUI_EnterKeyType KRTextAreaView::GetInputNodeEnterKeyType() { + // TextArea 场景需从 NODE_TEXT_AREA_ENTER_KEY_TYPE 读取,否则 OnInputReturn 回调 + // 拿到的 ime_action 会是 TextInput 属性上的默认值。 + auto item = kuikly::util::GetNodeApi()->getAttribute(GetNode(), NODE_TEXT_AREA_ENTER_KEY_TYPE); + return item ? static_cast(item->value[0].i32) : ARKUI_ENTER_KEY_TYPE_NEW_LINE; +} + void KRTextAreaView::UpdateInputNodeMaxLength(int maxLength) { ArkUI_NumberValue value[] = {{.i32 = maxLength}}; ArkUI_AttributeItem item = {value, sizeof(value) / sizeof(ArkUI_NumberValue)}; - kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_INPUT_MAX_LENGTH, &item); + // KRTextAreaView 底层是 ARKUI_NODE_TEXT_AREA,需写 NODE_TEXT_AREA_MAX_LENGTH, + // 否则 maxLength 不生效(此前误写为 NODE_TEXT_INPUT_MAX_LENGTH)。 + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_MAX_LENGTH, &item); } uint32_t KRTextAreaView::GetInputNodeSelectionStartPosition() { @@ -103,6 +120,13 @@ void KRTextAreaView::UpdateInputNodeSelectionStartPosition(uint32_t index) { ArkUI_AttributeItem item = {value.data(), value.size()}; kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_TEXT_SELECTION, &item); } +void KRTextAreaView::UpdateInputNodeSelectionRange(int32_t start, int32_t end) { + // KRTextAreaView 底层是 ARKUI_NODE_TEXT_AREA,需写 NODE_TEXT_AREA_TEXT_SELECTION, + // 否则区间选区会被写到 TextInput 属性上,表现为选区不生效。 + std::array value = {{{.i32 = start}, {.i32 = end}}}; + ArkUI_AttributeItem item = {value.data(), value.size()}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_AREA_TEXT_SELECTION, &item); +} std::pair KRTextAreaView::GetInputNodeTextSelectionRange() { auto item = kuikly::util::GetNodeApi()->getAttribute(GetNode(), NODE_TEXT_AREA_TEXT_SELECTION); if (item && item->size >= 2) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h index 3d7ccca45..c96cd2d22 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextAreaView.h @@ -58,9 +58,12 @@ class KRTextAreaView : public KRTextFieldView { void UpdateInputNodeCaretrColor(const std::string &propValue) override; void UpdateInputNodeSelectionColor(const std::string &propValue) override; void UpdateInputNodeKeyboardType(const std::string &propValue) override; + void UpdateInputNodeEnterKeyType(const std::string &propValue) override; + ArkUI_EnterKeyType GetInputNodeEnterKeyType() override; void UpdateInputNodeMaxLength(int maxLength) override; uint32_t GetInputNodeSelectionStartPosition() override; void UpdateInputNodeSelectionStartPosition(uint32_t index) override; + void UpdateInputNodeSelectionRange(int32_t start, int32_t end) override; std::pair GetInputNodeTextSelectionRange() override; void UpdateInputNodePlaceholderFont(uint32_t font_size, ArkUI_FontWeight font_weight) override; void UpdateInputNodeContentText(const std::string &text) override; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp index 4bfb90417..d65f4bcb9 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp @@ -135,6 +135,9 @@ void KRTextFieldView::UpdateInputNodeKeyboardType(const std::string& propValue){ void KRTextFieldView::UpdateInputNodeEnterKeyType(const std::string& propValue){ kuikly::util::UpdateInputNodeEnterKeyType(GetNode(), kuikly::util::ConvertToEnterKeyType(propValue)); } +ArkUI_EnterKeyType KRTextFieldView::GetInputNodeEnterKeyType(){ + return kuikly::util::GetInputNodeEnterKeyType(GetNode()); +} void KRTextFieldView::UpdateInputNodeMaxLength(int maxLength){ kuikly::util::UpdateInputNodeMaxLength(GetNode(), maxLength); // 直接限制 } @@ -149,6 +152,14 @@ void KRTextFieldView::UpdateInputNodeSelectionStartPosition(uint32_t index){ kuikly::util::UpdateInputNodeSelectionStartPosition(GetNode(), index); } +void KRTextFieldView::UpdateInputNodeSelectionRange(int32_t start, int32_t end){ + // 基类默认写 NODE_TEXT_INPUT_TEXT_SELECTION,KRTextAreaView 会 override 为 + // NODE_TEXT_AREA_TEXT_SELECTION,适配 ARKUI_NODE_TEXT_AREA 节点。 + std::array value = {{{.i32 = start}, {.i32 = end}}}; + ArkUI_AttributeItem item = {value.data(), value.size()}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_INPUT_TEXT_SELECTION, &item); +} + void KRTextFieldView::UpdateInputNodePlaceholderFont(uint32_t font_size, ArkUI_FontWeight font_weight){ const auto &rootView = GetRootView().lock(); bool fontSizeScaleFollowSystem = true; @@ -418,15 +429,14 @@ std::pair KRTextFieldView::GetInputNodeTextSelectionRange() } /** - * 受控写入 textInputState:解析 JSON 并把 text/光标 写入 ArkUI 节点。 + * 受控写入 textInputState:解析 JSON 并把 text/选区 写入 ArkUI 节点。 * * 跨端语义参考 Android KRTextFieldView.setTextInputState: * - 仅消费 text / selectionStart / selectionEnd 三字段; * - composition 不消费; * - * ⚠️ OHOS 老节点能力局限:selection 范围写入降级为「只把光标设到 selectionStart」。 - * TODO:后续如有真选区需求,可改用 NODE_TEXT_INPUT_TEXT_SELECTION / NODE_TEXT_AREA_TEXT_SELECTION - * 的 [start,end] 写入。Q1 已先接受降级。 + * selection 通过 UpdateInputNodeSelectionRange 写入真实 [start, end] 区间 + * (TextInput / TextArea 均支持),不再回退为折叠光标。 */ void KRTextFieldView::SetTextInputStateInternal(const std::string &json) { // KRRenderValue::toMap 内部调 cJSON_Parse 解析 JSON 字符串到 Map;解析失败回空 Map。 @@ -458,25 +468,25 @@ void KRTextFieldView::SetTextInputStateInternal(const std::string &json) { int u16_len = GetUTF16Length(text); int selection_start = get_int(kKeySelectionStart, u16_len); selection_start = std::max(0, std::min(selection_start, u16_len)); - // selection_end 解析但当前降级为不使用(Q1 TODO);预留以便日后实现真选区。 int selection_end = get_int(kKeySelectionEnd, selection_start); - (void)selection_end; + selection_end = std::max(selection_start, std::min(selection_end, u16_len)); is_setting_text_input_state_ = true; SetContentText(text); // ⚠️ ArkUI NODE_TEXT_INPUT_TEXT/NODE_TEXT_AREA_TEXT 的 setAttribute 会在内部异步触发 - // onChange,并把光标重置到文本末尾。如果在这里同步调用 UpdateInputNodeSelectionStartPosition, + // onChange,并把光标重置到文本末尾。如果在这里同步调用 UpdateInputNodeSelectionRange, // 会被随后到来的 ArkUI 内部 caret reset 吞掉,表现为「光标永远跳到末尾」。 - // 解决:把光标修正 post 到 next-loop,等 ArkUI 内部 onChange 完成后再设选区, + // 解决:把选区修正 post 到 next-loop,等 ArkUI 内部 onChange 完成后再设选区, // 与 KRTextEditorFieldView 中 RunOnMainThreadForNextLoop 的策略一致,也与 LimitInputContentTextInMaxLength // 中已有的「先改文本后异步设光标」pattern 一致。 // 同时 is_setting_text_input_state_ flag 也延迟到此处清除,以覆盖 SetContentText 异步触发 // OnTextDidChanged 的整个时窗,避免业务把"末尾光标"的脏 textInputStateChange 写回来形成回环。 KRMainThread::RunOnMainThreadForNextLoop( - [weakSelf = weak_from_this(), selection_start]() { + [weakSelf = weak_from_this(), selection_start, selection_end]() { if (auto strongSelf = std::dynamic_pointer_cast(weakSelf.lock())) { - strongSelf->UpdateInputNodeSelectionStartPosition(static_cast(selection_start)); + strongSelf->UpdateInputNodeSelectionRange(static_cast(selection_start), + static_cast(selection_end)); strongSelf->is_setting_text_input_state_ = false; if (strongSelf->length_limit_type_ != -1) { strongSelf->NotifyTextInputStateChange(); @@ -646,7 +656,7 @@ void KRTextFieldView::OnInputReturn(ArkUI_NodeEvent *event) { if (input_return_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(GetContentText()); - auto returnKeyType = kuikly::util::GetInputNodeEnterKeyType(GetNode()); + auto returnKeyType = GetInputNodeEnterKeyType(); map["ime_action"] = NewKRRenderValue(kuikly::util::ConvertEnterKeyTypeToString(returnKeyType)); input_return_callback_(NewKRRenderValue(map)); @@ -813,7 +823,7 @@ void KRTextFieldView::OnWillInsertText(ArkUI_NodeEvent *event) { OH_ArkUI_NodeEvent_GetStringValue(event, 0, &pBuffer, &size); // KR_LOG_DEBUG << "OnWillInsertText: to insert text: " << buffer; auto destText = GetContentText(); - auto range = kuikly::util::GetInputNodeSelectionRange(GetNode()); + auto range = GetInputNodeTextSelectionRange(); bool filtered = filter(buffer, destText, range.first, range.second); if (filtered || strlen(buffer) >= MAX_INSERT_LENGTH - 1) { if (filtered) { @@ -850,7 +860,7 @@ void KRTextFieldView::OnPasteText(ArkUI_NodeEvent *event) { strncpy(buffer, stringAsyncEvent->pStr, size); buffer[size] = '\0'; auto destText = GetContentText(); - auto range = kuikly::util::GetInputNodeSelectionRange(GetNode()); + auto range = GetInputNodeTextSelectionRange(); if (filter(buffer, destText, range.first, range.second)) { KR_LOG_DEBUG << "OnPasteText beyond limit"; // 超过最大输入长度限制 diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h index 37898e447..0cc34f4a3 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h @@ -79,10 +79,22 @@ class KRTextFieldView : public IKRRenderViewExport { virtual void UpdateInputNodeFocusable(int propValue); virtual void UpdateInputNodeKeyboardType(const std::string &propValue); virtual void UpdateInputNodeEnterKeyType(const std::string &propValue); + /** + * 读取 ArkUI 节点上的 EnterKeyType。 + * 子类(如 KRTextAreaView)需 override 以读 NODE_TEXT_AREA_ENTER_KEY_TYPE, + * 否则会从 NODE_TEXT_INPUT_ENTER_KEY_TYPE 读到错误的枚举值。 + */ + virtual ArkUI_EnterKeyType GetInputNodeEnterKeyType(); virtual void UpdateInputNodeMaxLength(int maxLength); virtual bool UpdateInputNodeFocusStatus(int status); virtual uint32_t GetInputNodeSelectionStartPosition(); virtual void UpdateInputNodeSelectionStartPosition(uint32_t index); + /** + * 设置真实区间选区 [start, end](按 UTF-16 算)。 + * TextInput 与 TextArea 都支持 [start, end] 双端选区,无需再降级为折叠光标。 + * 子类(如 KRTextAreaView)需 override 以写 NODE_TEXT_AREA_TEXT_SELECTION。 + */ + virtual void UpdateInputNodeSelectionRange(int32_t start, int32_t end); /** * 获取选区范围 [start, end](按 UTF-16 算)。 * 子类(如 KRTextAreaView)可 override 以适配不同的 ArkUI 节点类型。 @@ -151,10 +163,8 @@ class KRTextFieldView : public IKRRenderViewExport { * - 仅消费 text / selectionStart / selectionEnd 三字段; * - composition 区不在 OHOS 老节点的可写能力内,忽略。 * - * ⚠️ 当前 OHOS 老节点的可写能力局限: - * - selection 范围写入降级为「只把光标设到 selectionStart」,不支持真选中态。 - * - TODO:后续如有需要,再用 NODE_TEXT_INPUT_TEXT_SELECTION / NODE_TEXT_AREA_TEXT_SELECTION - * 的 [start,end] 形式实现真选区。 + * selection 通过 UpdateInputNodeSelectionRange 写入真实 [start, end] 区间 + * (TextInput / TextArea 均支持),不再退化为折叠光标。 * * 主动写入期间通过 is_setting_text_input_state_ 抑制 textInputStateChange 回调, * 避免业务把状态写回来形成死循环。 diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp index 3707bf7fa..aece92a71 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.cpp @@ -13,6 +13,8 @@ * limitations under the License. */ +#include + #include "libohos_render/expand/components/scroller/KRScrollerView.h" #include @@ -148,7 +150,8 @@ void KRScrollerView::SetRenderViewFrame(const KRRect &frame) { if (!is_set_frame_) { is_set_frame_ = true; if (is_need_set_content_offset_) { - kuikly::util::SetArkUIContentOffset(GetNode(), first_offset_x_, first_offset_y_, first_animate_, first_duration_, first_curve_); + kuikly::util::SetArkUIContentOffset(GetNode(), first_offset_x_, first_offset_y_, first_animate_, + first_duration_, first_curve_, first_damping_); is_need_set_content_offset_ = false; } } @@ -432,6 +435,7 @@ void KRScrollerView::SetContentOffset(const KRAnyValue &value) { auto offset_y = content_offset_splits[1]->toFloat(); auto animate = content_offset_splits[2]->toBool(); auto duration = content_offset_splits.size() > 3 ? content_offset_splits[3]->toInt() : 0; + auto damping = content_offset_splits.size() > 4 ? content_offset_splits[4]->toFloat() : 0; auto curve = content_offset_splits.size() > 6 ? content_offset_splits[6]->toInt() : 0; if (!is_set_frame_) { @@ -440,10 +444,11 @@ void KRScrollerView::SetContentOffset(const KRAnyValue &value) { first_animate_ = animate; first_duration_ = duration; first_curve_ = curve; + first_damping_ = damping; is_need_set_content_offset_ = true; return; } - kuikly::util::SetArkUIContentOffset(GetNode(), offset_x, offset_y, animate, duration, curve); + kuikly::util::SetArkUIContentOffset(GetNode(), offset_x, offset_y, animate, duration, curve, damping); } void KRScrollerView::SetContentInset(const KRAnyValue &value) { @@ -472,7 +477,7 @@ void KRScrollerView::SetContentInset(const std::shared_ptr(); animate_option->SetDuration(200); + auto weak_this = std::weak_ptr(std::dynamic_pointer_cast(shared_from_this())); content_inset_animate_ = std::make_shared( - root_view->GetUIContextHandle(), animate_option, [this, top, start, bottom, end]() { - kuikly::util::SetArkUIMargin(content_view_->GetNode(), start, top, end, bottom); + root_view->GetUIContextHandle(), animate_option, [weak_this, top, start, bottom, end]() { + if (auto strong_this = weak_this.lock()) { + kuikly::util::SetArkUIMargin(strong_this->content_view_->GetNode(), start, top, end, bottom); + } }); - std::weak_ptr weakSelf = std::dynamic_pointer_cast(shared_from_this()); content_inset_animate_->SetCompleteCallback( - ArkUI_FinishCallbackType::ARKUI_FINISH_CALLBACK_LOGICALLY, [weakSelf]() { - if (std::shared_ptr strongSelf = weakSelf.lock()) { - strongSelf->content_inset_animate_ = nullptr; + ArkUI_FinishCallbackType::ARKUI_FINISH_CALLBACK_LOGICALLY, [weak_this]() { + if (auto strong_this = weak_this.lock()) { + strong_this->content_inset_animate_ = nullptr; } }); content_inset_animate_->Start(); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h index fe69d6b99..fa3db05ac 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerView.h @@ -157,6 +157,7 @@ class KRScrollerView : public IKRRenderViewExport { bool first_animate_ = false; int first_duration_ = 0; int first_curve_ = 0; + float first_damping_ = 0; ArkUI_ScrollState current_scroll_state_; std::shared_ptr content_inset_animate_; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp index 15a0fb3b6..7c44b9a1d 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.cpp @@ -182,14 +182,19 @@ void KRView::HandleCreateSelection(const KRAnyValue ¶ms) { if (x != INVALID_NUMBER && y != INVALID_NUMBER && type != INVALID_NUMBER) { CreateSelection(KRPoint(static_cast(x), static_cast(y)), - KRPoint(static_cast(x), static_cast(y)), type); + KRPoint(static_cast(x), static_cast(y)), type, true); } } } -void KRView::CalculateHandleFramesAndDoUpdate() { +void KRView::CalculateHandleFramesAndDoUpdate(bool from_user) { auto [selected_text_views, selected_scroll_views] = GetSelectedTextAndScrollViews(); if (selected_text_views.empty()) { + if (from_user && selection_info_.sent_start_event) { + // 业务主动创建选区但命中为空:结束上次仍激活的选区会话,与 Android 行为对齐。 + selection_info_.sent_start_event = false; + FireSelectionEvent(SelectionEventKind::CANCEL); + } return; } @@ -240,6 +245,8 @@ void KRView::CalculateHandleFramesAndDoUpdate() { std::dynamic_pointer_cast(selected_text_views.back())->GetSelectionInfo().last_char_width; } + KRRect old_start = selection_info_.start; + KRRect old_end = selection_info_.end; selection_info_.start = KRRect(first_selection_rect2.x, first_selection_rect2.y, SelectionCursorWidth, first_selection_rect2.height); selection_info_.end = KRRect(last_selection_rect2.x + last_selection_rect2.width, last_selection_rect2.y, @@ -249,10 +256,18 @@ void KRView::CalculateHandleFramesAndDoUpdate() { selection_info_.selection_points[1] = KRPoint(selection_info_.end.x - last_char_width / 2, selection_info_.end.y + selection_info_.end.height / 2); + bool rect_changed = (selection_info_.start != old_start) || (selection_info_.end != old_end); if (!selection_info_.sent_start_event) { FireSelectionEvent(SelectionEventKind::START); selection_info_.sent_start_event = true; + } else if (from_user) { + // 业务主动创建复用激活会话,与 Android 语义对齐: + // 仅当选区位置发生变化时才再次发 START;位置未变化则不再重复发事件,仅更新手柄。 + if (rect_changed) { + FireSelectionEvent(SelectionEventKind::START); + } } else { + // 拖拽手柄更新选区,发 CHANGE。 FireSelectionEvent(SelectionEventKind::CHANGE); } selection_info_.visible = true; @@ -260,9 +275,9 @@ void KRView::CalculateHandleFramesAndDoUpdate() { UpdateSelectionHandles(); } -void KRView::CreateSelection(KRPoint p0, KRPoint p1, int type) { +void KRView::CreateSelection(KRPoint p0, KRPoint p1, int type, bool from_user) { UpdateSelection(shared_from_this(), p0, p1, type); - CalculateHandleFramesAndDoUpdate(); + CalculateHandleFramesAndDoUpdate(from_user); } void KRView::HandleGetSelection(const KRAnyValue ¶ms, const KRRenderCallback &cb) { @@ -348,6 +363,7 @@ void KRView::HandleGetSelection(const KRAnyValue ¶ms, const KRRenderCallback void KRView::HandleClearSelection() { FireSelectionEvent(SelectionEventKind::CANCEL); + selection_info_.sent_start_event = false; selection_info_.visible = false; for (auto item : last_selected_text_views_) { @@ -358,7 +374,7 @@ void KRView::HandleClearSelection() { void KRView::HandleCreateSelectionAll() { KRRect bounds = GetBounds(); - CreateSelection(KRPoint(), KRPoint(bounds.width, bounds.height), KRTextSelectionType::ALL); + CreateSelection(KRPoint(), KRPoint(bounds.width, bounds.height), KRTextSelectionType::ALL, true); } bool KRView::HandleTextSelectionMethods(const std::string &method, const KRAnyValue ¶ms, diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h index 073e9f3a8..f00ef37ab 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/view/KRView.h @@ -52,7 +52,7 @@ class KRView : public IKRRenderViewExport { private: void StopObservingInternalScrollViews(); - void CalculateHandleFramesAndDoUpdate(); + void CalculateHandleFramesAndDoUpdate(bool from_user = false); void OnInternalScrollViewDidScroll(float offsetX, float offsetY); void EnsureRegisterTouchEvent(); bool RegisterTouchDownEvent(const KRRenderCallback &event_call_back); @@ -76,7 +76,7 @@ class KRView : public IKRRenderViewExport { void HandleClearSelection(); void HandleCreateSelectionAll(); - void CreateSelection(KRPoint point, KRPoint point2, int type); + void CreateSelection(KRPoint point, KRPoint point2, int type, bool from_user = false); std::vector> GetSelectedNodes(KRPoint p0, KRPoint p1); void GetSelectedNodes(std::shared_ptr root_render_view, diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/KRRect.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/KRRect.h index d33d0c0ea..5fa5af5b6 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/KRRect.h +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/KRRect.h @@ -61,6 +61,9 @@ struct KRRect { bool operator==(const KRRect &other) const { return x == other.x && y == other.y && width == other.width && height == other.height; } + bool operator!=(const KRRect &other) const { + return !(*this == other); + } // 零大小的静态常量成员 static const KRRect zero; diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.cpp b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.cpp index f2eb8c715..f3af91f4f 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.cpp @@ -23,13 +23,10 @@ #include #include -#include "libohos_render/foundation/thread/KRThreadFatalGuard.h" #include "libohos_render/utils/KRRenderLoger.h" namespace { -using kuikly::thread::RunWithFatalGuard; - struct PendingTask { std::function func; int delayMs; @@ -54,8 +51,10 @@ std::queue> g_pending_queue; // * uv_timer_init 返回值必须检查;失败时不能泄漏 timer/holder 堆内存, // 也不能静默丢弃 task——这里选择 log + 释放资源 + return(崩不是 // 责任,caller 应该能容忍 timer 创建失败这个极端低概率 case)。 -// * timer 回调里调用 user task 必须走 RunWithFatalGuard:taskcb 在 libuv 回调上下文 -// 里执行,异常越过 C 帧会造成 UB,与 KRThread::TimerCb.fallback 同口径。 +// * timer 回调里 user task 若抛异常会越过 libuv 的 C 帧造成 UB,但为了让 K/N +// unhandled-exception hook 能正常触发并打出 Kotlin 栈,这里刻意不再套 C++ +// catch —— catch 会让 K/N 观察到 "C++ 已处理" 从而抑制 hook。异常最终会 +// 沿 uv 回调冒到 std::terminate,与直接 abort 等价。 void StartTimerOnMainThread(std::function task, int delayMs) { auto *timer = new uv_timer_t(); auto *holder = new std::function(std::move(task)); @@ -73,8 +72,8 @@ void StartTimerOnMainThread(std::function task, int delayMs) { [](uv_timer_t *handle) { auto *fn = static_cast *>(handle->data); if (fn != nullptr) { - // libuv 回调边界:异常越 C 帧 = UB,这里必须 fail-fast。 - RunWithFatalGuard("KRMainThread.MainTimer.cb", *fn); + // libuv 回调边界:不套 C++ catch,让异常一路冒到 K/N unhandled hook。 + (*fn)(); } uv_timer_stop(handle); uv_close(reinterpret_cast(handle), [](uv_handle_t *h) { @@ -103,8 +102,8 @@ void StartTimerOnMainThread(std::function task, int delayMs) { // 主线程 uv_async 回调:把队列里所有任务取出,根据 delay 决定立即执行还是注册 uv_timer。 // 注意:本函数在主线程(loop 线程)执行,因此 uv_timer_init / uv_timer_start 都是合规的。 -// 异常路径:user task 是业务提供的回调,本函数有 libuv async 回调上下文、异常逃出 -// 会越 C 帧 UB,所以 inline 路径 fail-fast;delay > 0 路径交给 timer cb 里的 guard 处理。 +// 异常路径:user task 是业务提供的回调;不套 C++ catch,让异常直接冒到 K/N +// unhandled hook 触发 Kotlin 侧崩溃诊断。inline 路径与 delay > 0 路径口径一致。 void OnMainAsync(uv_async_t * /*handle*/) { std::queue> local; { @@ -118,7 +117,7 @@ void OnMainAsync(uv_async_t * /*handle*/) { continue; } if (pending->delayMs <= 0) { - RunWithFatalGuard("KRMainThread.MainAsync.batch", pending->func); + pending->func(); } else { StartTimerOnMainThread(std::move(pending->func), pending->delayMs); } @@ -176,10 +175,9 @@ void KRMainThread::RunOnMainThread(std::function task, int delayMillisec } if (!g_initialized.load() || g_main_loop == nullptr) { // 尚未初始化(理论上不应发生),降级为同步执行以避免任务丢失。 - // 本 fallback 路径本身不在 libuv 回调上下文,但 caller 期待“调用后 task - // 安全运行”,同样需要边界 fail-fast,与 libuv 路径口径一致。 + // 不套 C++ catch:异常若发生则直接冒到 caller 栈,最终由 K/N unhandled hook 处理。 KR_LOG_ERROR << "KRMainThread::RunOnMainThread before Export, fallback to inline run"; - RunWithFatalGuard("KRMainThread.Inline.fallback", task); + task(); return; } @@ -187,11 +185,8 @@ void KRMainThread::RunOnMainThread(std::function task, int delayMillisec // 已经在主线程(loop 线程),可以直接安全地操作 uv 句柄。 if (delayMilliseconds <= 0) { // 立即执行:保持与原实现一致的"同步直跑"语义。 - // 这里 caller 可能是任意业务栈帧(业务组件在主线程调用 RunOnMainThread), - // 严格说允许异常逃出 caller 也是合法的;但为了跟 libuv 路径同口径、 - // 且避免 caller 在"主线程 inline" vs "跨线程异步" 两种环境下行为不一致, - // 这里同样走 fail-fast。 - RunWithFatalGuard("KRMainThread.Inline.same-thread", task); + // 不套 C++ catch:异常若发生则直接冒到 caller 栈,最终由 K/N unhandled hook 处理。 + task(); } else { StartTimerOnMainThread(std::move(task), delayMilliseconds); } @@ -219,7 +214,7 @@ void KRMainThread::RunOnMainThreadForNextLoop(std::function task) { } if (!g_initialized.load() || g_main_loop == nullptr) { KR_LOG_ERROR << "KRMainThread::RunOnMainThreadForNextLoop before Export, fallback to inline run"; - RunWithFatalGuard("KRMainThread.Inline.fallback", task); + task(); return; } // 不论当前是否在主线程,都强制走 uv_async 投递,保证在"下一次 loop 回合"才执行。 diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.cpp b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.cpp index 4e0a23dcf..26480a77e 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.cpp @@ -21,13 +21,10 @@ #include #include -#include "libohos_render/foundation/thread/KRThreadFatalGuard.h" #include "libohos_render/utils/KRRenderLoger.h" namespace { -using kuikly::thread::RunWithFatalGuard; - // 一次性 timer 持有的上下文:exec 为“到点后的提交动作”,在 TimerCb 中被调用。 struct TimerContext { std::function exec; @@ -202,8 +199,11 @@ void KRThread::OnAsync() { if (fn) { // 任何未捕获异常都会一路冒到 std::thread 入口触发 std::terminate, // 同时让 m_taskMutex / m_isExecutingTask 来不及落回干净状态—— - // 这里 fail-fast,让崩溃栈停在第一现场。 - RunWithFatalGuard("KRThread.OnAsync.batch", fn); + // 这里不套 C++ catch,为的是让 K/N unhandled hook 能先于 std::terminate + // 触发、打出完整 Kotlin 侧崩溃栈(catch 会让 K/N 观察到 "C++ 已处理" + // 从而抑制 hook)。同时靠 std::mutex / std::atomic 的 RAII 保证 unwind + // 将 m_taskMutex 释放、m_isExecutingTask 下文恢复。 + fn(); } } m_isExecutingTask.store(false); @@ -261,7 +261,7 @@ void KRThread::TimerCb(uv_timer_t *handle) { // 绝不能在此裸跑 ctx->exec:正常路径 task 必须在 worker 线程且受 // m_taskMutex 保护执行,fallback 直接调用会绕开互斥语义、并可能与 // 正在借位执行的 DirectRunOnCurThread 并发踩踏 kuikly 上下文。 - // RunWithFatalGuard 只挡异常,不挡数据竞争 —— 这里选择丢弃任务。 + // 这里不依赖任何 C++ catch,直接丢弃任务。 // // 双层策略(与 OnAsync 未知句柄分支保持一致): // * debug:assert(false) 让状态损坏第一时间暴露到崩溃栈; @@ -323,15 +323,11 @@ void KRThread::DirectRunOnCurThread(const std::function &task) { } if (m_isExecutingTask.load() && IsCurrentThreadWorkerThread()) { // 仅当“当前就在 worker 的执行栈里(task 体内嵌套调用)”才允许直跑, - // 避免外部线程在 worker 持锁跑批期间错误地“白嘍”执行权造成数据竞争。 + // 避免外部线程在 worker 持锁跑批期间错误地“白嚘”执行权造成数据竞争。 // - // 异常语义(fail-forward):由 RunWithFatalGuard 在 catch 里打完整 - // 诊断日志(tag + demangled 类型 + e.what())后 rethrow,让异常继续 - // unwind,直至 K/N runtime 的 unhandled-exception hook(若有)先跑 - // 打出 Kotlin 栈,最终 std::terminate → abort 终止进程。 - // 这样既保留 fail-fast 精神,又不会像直接 abort 那样吞掉 K/N 的 - // Kotlin 侧崩溃信息。 - RunWithFatalGuard("KRThread.DirectRunOnCurThread.nested", task); + // 异常语义:不套 C++ catch,让异常一路冒到 K/N unhandled hook,避免 + // “C++ 已处理”误判拖喽 hook 触发而丢失 Kotlin 侧崩溃信息。 + task(); return; } @@ -345,8 +341,9 @@ void KRThread::DirectRunOnCurThread(const std::function &task) { } std::unique_lock taskLock(m_taskMutex, std::try_to_lock); if (taskLock.owns_lock()) { - // 借位执行 task。异常语义与 nested 分支一致:RunWithFatalGuard 会 - // 在 catch 里打诊断日志再 rethrow;rethrow 期间 unwind 会自动展开 + // 借位执行 task。不套 C++ catch,让异常一路冒到 K/N unhandled hook: + // 任何中间层 catch(即使手动 rethrow)都会让 K/N 观察到 "C++ 已处理" + // 从而不再触发 hook,导致丢失 Kotlin 侧崩溃栈。unwind 期间会自动展开 // 下面的 ExecutingFlagGuard 与 taskLock(unique_lock),保证 // m_isExecutingTask / m_taskMutex 状态一致,不残留中间态。 // @@ -358,7 +355,7 @@ void KRThread::DirectRunOnCurThread(const std::function &task) { explicit ExecutingFlagGuard(std::atomic &f) : flag(f) { flag.store(true); } ~ExecutingFlagGuard() { flag.store(false); } } execFlagGuard(m_isExecutingTask); - RunWithFatalGuard("KRThread.DirectRunOnCurThread.borrow", task); + task(); didHandleTask = true; break; } diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThreadFatalGuard.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThreadFatalGuard.h deleted file mode 100644 index ad4d55660..000000000 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThreadFatalGuard.h +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Tencent is pleased to support the open source community by making KuiklyUI - * available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the License of KuiklyUI; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#ifndef CORE_RENDER_OHOS_KRTHREADFATALGUARD_H -#define CORE_RENDER_OHOS_KRTHREADFATALGUARD_H - -#include -#include -#include -#include -#include - -#include "libohos_render/utils/KRRenderLoger.h" - -namespace kuikly { -namespace thread { - -// 拿到当前 catch 分支正在处理的异常的可读类型名。 -// * 使用 abi::__cxa_current_exception_type() 获取 std::type_info; -// 该 API 只在 catch 块中调用才有意义,其他上下文会返回 nullptr。 -// * 结果用 abi::__cxa_demangle 反修饰,方便识别形如 -// "kotlin::ObjHolder"、"IncorrectDereferenceException" 之类 -// 不继承 std::exception 的 K/N 异常类型。 -// * 无法获取时返回 "",避免污染日志格式。 -inline std::string CurrentExceptionTypeName() { - const std::type_info *ti = abi::__cxa_current_exception_type(); - if (ti == nullptr) { - return ""; - } - const char *mangled = ti->name(); - if (mangled == nullptr) { - return ""; - } - int status = 0; - char *demangled = abi::__cxa_demangle(mangled, nullptr, nullptr, &status); - if (status == 0 && demangled != nullptr) { - std::string result(demangled); - std::free(demangled); - return result; - } - // demangle 失败则退回 mangled name,好过没有信息。 - return std::string(mangled); -} - -// 统一的"调度边界 fail-forward"语义(catch → 日志 → rethrow): -// * 所有跨线程/跨语言(C++ ↔ ArkTS / libuv 回调 / std::thread 入口)的"task 执行" -// 调度边界都应该用这个 guard 包裹。 -// * 设计动机: -// - libuv 回调 / std::thread 入口 / napi C ABI 里放任 C++ 异常自然逃出会 UB -// 或 std::terminate 无 unwind,crash 现场不可读; -// - 但**直接 abort()** 会抢在 K/N runtime 的 unhandled-exception hook 之前, -// 吞掉 Kotlin 侧真正有价值的 Throwable class / message / Kotlin 栈; -// - 折中方案:先在 catch 里打完整诊断日志(tag + demangled 类型名 + e.what()), -// 再 `throw;` 让异常继续 unwind。unwind 一路到 `std::terminate()` -// 等价于 `std::abort()`,但 K/N runtime 挂在那条路径上的 unhandled hook -// 有机会先跑并打出 Kotlin 栈;同时 RAII 会正常展开,避免 mutex/标志位残留。 -// * 行为: -// 1. `try { task(); }` 正常路径直通; -// 2. `catch (std::exception&)` / `catch (...)`:打 KR_LOG_ERROR -// (含 tag、demangled type、e.what()),然后 `throw;` 继续 unwind; -// 3. 异常最终由 K/N unhandled hook 或 `std::terminate`(→ `abort`)终止进程。 -// * 语义要点: -// - 保留 fail-fast 精神(进程一定终止),但把"终止方式"从 abort 改为 rethrow, -// 把 abort 决策权让渡给运行时(K/N hook / std::terminate handler); -// - `throw;` 沿用原始异常对象,不产生新异常,`std::current_exception()` -// 语义保持不变; -// - 上层 caller 需要预期本函数**可能向外抛异常**,若上层想"吸收异常继续运行" -// 必须自行套 catch —— 但当前工程约定就是 fail-fast,不建议这么做。 -// * 适用点(截至本提交): -// - KRThread: OnAsync.batch / TimerCb.fallback / DirectRunOnCurThread.{nested,borrow} -// - KRMainThread: MainAsync.batch / MainTimer.cb / Inline.same-thread / Inline.fallback -// - KRRenderCore: ABI.CallNative (napi C ABI 边界,异常在这里会被最外层 -// 由 catch 打 log + rethrow → std::terminate;由于这里已经是 napi ABI 边界, -// rethrow 后异常会到达 std::terminate,与 abort 等价,但保留了 K/N hook 触发窗口) -template -inline void RunWithFatalGuard(const char *tag, F &&task) { - try { - std::forward(task)(); - } catch (const std::exception &e) { - KR_LOG_ERROR << "[" << tag << "] std::exception at dispatch boundary" - << " (type=" << CurrentExceptionTypeName() << ")" - << ": " << e.what() - << "; rethrowing to let K/N unhandled-exception hook run."; - throw; - } catch (...) { - KR_LOG_ERROR << "[" << tag << "] non-std exception at dispatch boundary" - << " (type=" << CurrentExceptionTypeName() << ")" - << "; rethrowing to let K/N unhandled-exception hook run."; - throw; - } -} - -} // namespace thread -} // namespace kuikly - -#endif // CORE_RENDER_OHOS_KRTHREADFATALGUARD_H diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderCValue.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderCValue.h index ac5d207b4..3d567affd 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderCValue.h +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderCValue.h @@ -49,8 +49,8 @@ extern "C" { typedef void (*CallKotlin)(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, KRRenderCValue arg2, KRRenderCValue arg3, KRRenderCValue arg4, KRRenderCValue arg5); extern int com_tencent_kuikly_SetCallKotlin(CallKotlin callKotlin); -extern const KRRenderCValue com_tencent_kuikly_CallNative(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, - KRRenderCValue arg2, KRRenderCValue arg3, KRRenderCValue arg4, - KRRenderCValue arg5); +extern void com_tencent_kuikly_CallNative(int methodId, const KRRenderCValue *arg0, const KRRenderCValue *arg1, + const KRRenderCValue *arg2, const KRRenderCValue *arg3, const KRRenderCValue *arg4, + const KRRenderCValue *arg5, KRRenderCValue *result); } #endif // CORE_RENDER_OHOS_KRRENDERCVALUE_H diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderValue.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderValue.h index a867ac778..7b2a1f081 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderValue.h +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/type/KRRenderValue.h @@ -18,7 +18,6 @@ #include #include -#include #include #include #include @@ -98,10 +97,22 @@ class KRRenderValue : public std::enable_shared_from_this { /** * 统一的工厂方法,确保所有实例都通过 shared_ptr 管理 * 用法: KRRenderValue::Make(), KRRenderValue::Make(42), KRRenderValue::Make("hello") + * + * 特殊优化:Make() 和 Make("") 返回复用的静态单例对象,避免重复创建和析构 */ template static std::shared_ptr Make(Args&&... args); + /** + * 特化版本:返回复用的空值(null)单例对象 + */ + static std::shared_ptr MakeNull(); + + /** + * 特化版本:返回复用的空字符串单例对象 + */ + static std::shared_ptr MakeEmptyString(); + protected: KRRenderValue() { value_ = std::monostate(); @@ -538,63 +549,54 @@ class KRRenderValue : public std::enable_shared_from_this { } const KRRenderCValue &toCValue() const { - if (c_value_initialized_.load(std::memory_order_acquire)) { - return c_value_; - } - - std::lock_guard lock(c_value_mutex_); - if (c_value_initialized_.load(std::memory_order_relaxed)) { - return c_value_; - } - - if (isBool()) { - c_value_.type = KRRenderCValue::Type::BOOL; - c_value_.value.boolValue = toBool() ? 1 : 0; - } else if (isInt()) { - c_value_.type = KRRenderCValue::Type::INT; - c_value_.value.intValue = toInt(); - } else if (isLong()) { - c_value_.type = KRRenderCValue::Type::LONG; - c_value_.value.longValue = toLong(); - } else if (isFloat()) { - c_value_.type = KRRenderCValue::Type::FLOAT; - c_value_.value.floatValue = toFloat(); - } else if (isDouble()) { - c_value_.type = KRRenderCValue::Type::DOUBLE; - c_value_.value.doubleValue = toDouble(); - } else if (isString()) { - c_value_.type = KRRenderCValue::Type::STRING; - cached_string_for_c_value_ = std::get(value_); - c_value_.value.stringValue = const_cast(cached_string_for_c_value_.c_str()); - } else if (isByteArray()) { - c_value_.type = KRRenderCValue::Type::BYTES; - auto byte_array = std::get(value_).get(); - c_value_.size = byte_array->size(); - c_value_.value.bytesValue = reinterpret_cast(byte_array->data()); - } else if (isMap()) { - ToJsonMapOrArrayLocked(); - } else if (isArray()) { - auto array = toArray(); - if (HadByteArrayElement(array)) { // 有二进制元素的话, 不进行 json 序列化,直接传递数组 - c_value_.type = KRRenderCValue::Type::ARRAY; - c_value_.size = array.size(); - if (array_ptr_ != nullptr) { - delete[] array_ptr_; - } - array_ptr_ = new KRRenderCValue[c_value_.size]; - for (size_t i = 0; i < c_value_.size; i++) { - const auto &item = array[i]; - array_ptr_[i] = item->toCValue(); + std::call_once(c_value_once_flag_, [this]() { + if (isBool()) { + c_value_.type = KRRenderCValue::Type::BOOL; + c_value_.value.boolValue = toBool() ? 1 : 0; + } else if (isInt()) { + c_value_.type = KRRenderCValue::Type::INT; + c_value_.value.intValue = toInt(); + } else if (isLong()) { + c_value_.type = KRRenderCValue::Type::LONG; + c_value_.value.longValue = toLong(); + } else if (isFloat()) { + c_value_.type = KRRenderCValue::Type::FLOAT; + c_value_.value.floatValue = toFloat(); + } else if (isDouble()) { + c_value_.type = KRRenderCValue::Type::DOUBLE; + c_value_.value.doubleValue = toDouble(); + } else if (isString()) { + c_value_.type = KRRenderCValue::Type::STRING; + cached_string_for_c_value_ = std::get(value_); + c_value_.value.stringValue = const_cast(cached_string_for_c_value_.c_str()); + } else if (isByteArray()) { + c_value_.type = KRRenderCValue::Type::BYTES; + auto byte_array = std::get(value_).get(); + c_value_.size = byte_array->size(); + c_value_.value.bytesValue = reinterpret_cast(byte_array->data()); + } else if (isMap()) { + ToJsonMapOrArrayLocked(); + } else if (isArray()) { + auto array = toArray(); + if (HadByteArrayElement(array)) { // 有二进制元素的话, 不进行 json 序列化,直接传递数组 + c_value_.type = KRRenderCValue::Type::ARRAY; + c_value_.size = array.size(); + if (array_ptr_ != nullptr) { + delete[] array_ptr_; + } + array_ptr_ = new KRRenderCValue[c_value_.size]; + for (size_t i = 0; i < c_value_.size; i++) { + const auto &item = array[i]; + array_ptr_[i] = item->toCValue(); + } + c_value_.value.arrayValue = array_ptr_; + } else { + ToJsonMapOrArrayLocked(); } - c_value_.value.arrayValue = array_ptr_; } else { - ToJsonMapOrArrayLocked(); + c_value_.type = KRRenderCValue::Type::NULL_VALUE; } - } else { - c_value_.type = KRRenderCValue::Type::NULL_VALUE; - } - - c_value_initialized_.store(true, std::memory_order_release); + }); return c_value_; } @@ -713,8 +715,7 @@ class KRRenderValue : public std::enable_shared_from_this { NapiValue> value_; - mutable std::mutex c_value_mutex_; - mutable std::atomic c_value_initialized_{false}; + mutable std::once_flag c_value_once_flag_; mutable std::string map_or_array_json_value_; // 缓存经过序列化的 map或者 array, 用于缓存经过序列化的std::string mutable std::string cached_string_for_c_value_; mutable KRRenderCValue c_value_; @@ -799,7 +800,7 @@ class KRRenderValue : public std::enable_shared_from_this { static std::shared_ptr fromJsonValue(const cJSON *cjson) { if(cjson == nullptr){ - return Make(); + return MakeNull(); } if (cJSON_IsBool(cjson)) { return Make(cJSON_IsTrue(cjson)); @@ -821,7 +822,7 @@ class KRRenderValue : public std::enable_shared_from_this { } return Make(vec_obj); } else { - return Make(); // Null JSValue + return MakeNull(); // Null JSValue } } }; @@ -833,7 +834,52 @@ struct KRRenderValue::Accessor : KRRenderValue { template std::shared_ptr KRRenderValue::Make(Args&&... args) { - return std::make_shared(std::forward(args)...); + if constexpr (sizeof...(args) == 0) { + return MakeNull(); + } else { + return std::make_shared(std::forward(args)...); + } +} + +inline std::shared_ptr KRRenderValue::MakeNull() { + static std::shared_ptr sNullValue = std::make_shared(); + return sNullValue; +} + +inline std::shared_ptr KRRenderValue::MakeEmptyString() { + static std::shared_ptr sEmptyStringValue = std::make_shared(std::string("")); + return sEmptyStringValue; +} + +// Make(const char*) 特化:空字符串返回复用的单例对象 +// 注:签名 const char*&& 是主模板 Make(Args&&... args) 在 Args = const char* 时的 +// 实例化形式。C++ 模板特化必须精确匹配主模板签名,不能改为 const char*, +// 否则该特化不会被主模板匹配到,Make("") 的空字符串单例复用优化将失效。 +template<> +inline std::shared_ptr KRRenderValue::Make(const char* &&value) { + if (value == nullptr || value[0] == '\0') { + return MakeEmptyString(); + } + return std::make_shared(std::forward(value)); +} + +// Make(KRRenderCValue) 特化:对 NULL 和常用小整数返回复用的单例对象,减少堆分配 +template<> +inline std::shared_ptr KRRenderValue::Make(const KRRenderCValue &value) { + if (value.type == KRRenderCValue::NULL_VALUE) { + return MakeNull(); + } + // 缓存常用小整数 0-3(覆盖 syncCall 的 0/1/2/3 常用值) + if (value.type == KRRenderCValue::INT && value.value.intValue >= 0 && value.value.intValue <= 3) { + static std::shared_ptr sCachedInts[4] = { + std::make_shared(int32_t(0)), + std::make_shared(int32_t(1)), + std::make_shared(int32_t(2)), + std::make_shared(int32_t(3)), + }; + return sCachedInts[value.value.intValue]; + } + return std::make_shared(value); } #endif // CORE_RENDER_OHOS_KRRENDERVALUE_H diff --git a/core-render-ohos/src/main/cpp/libohos_render/layer/KRRenderLayerHandler.cpp b/core-render-ohos/src/main/cpp/libohos_render/layer/KRRenderLayerHandler.cpp index 2b3d6e7fc..e8b232bcf 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/layer/KRRenderLayerHandler.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/layer/KRRenderLayerHandler.cpp @@ -368,10 +368,16 @@ std::shared_ptr KRRenderLayerHandler::GetModuleOrCreate(c return nullptr; } - // 特殊情况,判断是否需要使用新实现的KROhSharedPreferencesModule - bool useOhSharedPreferences = this->root_view_.lock()->GetContext()->Config()->GetUseOhSharedPreferences(); - // 如果调用的是 KRSharedPreferencesModule 并且 启用新SharedPreferencesModule,返回KROhSharedPreferencesModule - std::string target_module_name = (module_name == "KRSharedPreferencesModule" && useOhSharedPreferences? "KROhSharedPreferencesModule" : module_name); + // 只在需要时(KRSharedPreferencesModule)才做 root_view_.lock() -> GetContext() -> Config() 调用链 + std::string target_module_name = module_name; + if (module_name == "KRSharedPreferencesModule") { + if (auto root = root_view_.lock()) { + bool useOhSharedPreferences = root->GetContext()->Config()->GetUseOhSharedPreferences(); + if (useOhSharedPreferences) { + target_module_name = "KROhSharedPreferencesModule"; + } + } + } auto module = GetModule(target_module_name); if (module == nullptr) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/manager/KRRenderManager.cpp b/core-render-ohos/src/main/cpp/libohos_render/manager/KRRenderManager.cpp index d236c0c9d..9c0997866 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/manager/KRRenderManager.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/manager/KRRenderManager.cpp @@ -74,10 +74,11 @@ void KRRenderManager::Export(napi_env env, napi_value exports) { std::shared_ptr KRRenderManager::GetRenderView(const std::string &instanceId) { KRScopedSpinLock lock(&render_view_map_lock_); - if (render_view_map_.find(instanceId) == render_view_map_.end()) { + auto it = render_view_map_.find(instanceId); + if (it == render_view_map_.end()) { return nullptr; } - return render_view_map_[instanceId]; + return it->second; } bool KRRenderManager::SetRenderView(std::string &instanceId, std::shared_ptr &renderView) { diff --git a/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.cpp b/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.cpp index d1c20b4ca..a85d67120 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.cpp @@ -135,8 +135,8 @@ void KRContextSchedulerMultiThreaded::ScheduleTaskOnMainThread(bool sync, const if (doneFuture.wait_for(kSyncMainTaskWarnTimeout) == std::future_status::timeout) { // 各路径 fail-fast 同口径。throw 出去也走不到任何业务可达的 catch 点: // - ToCallArkTSMethod / SyncCallArkTSMethod / KRForwardArkTSModule 都不接异常, - // - 一路冒到 napi C ABI 边界被 KRRenderCore.ABI.CallNative 的 - // RunWithFatalGuard 接住 → std::abort()。 + // - 一路冒到 napi C ABI 边界后直接暴露给 K/N runtime,由 K/N 的 + // unhandled-exception hook 打出完整 Kotlin 栈后 std::terminate。 // 所以这里直接走 __assert_fail 让 coredump 直接携带 file:line:func, // 避免栈 unwind 现场失真;也不会让 caller 误以为“这个 throw 可以 catch” // 这种 API 双重含义陷阱。裸调 __assert_fail(而非 assert 宏)确保 release diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp index 815d255ee..51af822a8 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.cpp @@ -859,7 +859,8 @@ KRPoint GetArkUIScrollContentOffset(ArkUI_NodeHandle handle) { return item ? KRPoint{item->value[0].f32, item->value[1].f32} : KRPoint(); } -void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset_y, bool animate, int duration, int curve) { +void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset_y, bool animate, int duration, int curve, + float damping) { if (!handle) { return; } @@ -867,16 +868,23 @@ void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset if (duration < 0) { duration = 0; } + int durationForArkUI = duration; int enableDefaultSpringAnimation = animate ? 1 : 0; if (duration > 0 && animate) { - // Default spring animation should be disabled when custom animation duration is specified, - // otherwise custom animation duration will not take effect. - enableDefaultSpringAnimation = 0; + if (curve == 0 && damping == 1.0f) { + // Align with Android: use the platform default scroll animation when no extra spring effect is needed. + durationForArkUI = 0; + enableDefaultSpringAnimation = 1; + } else { + // Default spring animation should be disabled when custom animation duration is specified, + // otherwise custom animation duration will not take effect. + enableDefaultSpringAnimation = 0; + } } ArkUI_NumberValue value[] = { {.f32 = offset_x}, {.f32 = offset_y}, - {.i32 = duration}, + {.i32 = durationForArkUI}, {.i32 = curve == 0 ? ARKUI_CURVE_EASE : ARKUI_CURVE_LINEAR}, {.i32 = enableDefaultSpringAnimation}, // whether to enable the default spring animation {.i32 = 1}, // whether scrolling can cross the boundary diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h index 2f9eb886d..3cf839674 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h @@ -186,7 +186,8 @@ void SetArkUIScrollEnabled(ArkUI_NodeHandle handle, bool enable); KRPoint GetArkUIScrollContentOffset(ArkUI_NodeHandle handle); -void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset_y, bool animate, int duration, int curve); +void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset_y, bool animate, int duration, int curve, + float damping); ArkUI_ScrollState GetArkUIScrollerState(ArkUI_NodeEvent *event, int scroll_state_index); diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/animate/KRAnimation.h b/core-render-ohos/src/main/cpp/libohos_render/utils/animate/KRAnimation.h index fc1399201..a569856b8 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/animate/KRAnimation.h +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/animate/KRAnimation.h @@ -17,20 +17,11 @@ #define CORE_RENDER_OHOS_KRANIMATION_H #include +#include #include "libohos_render/foundation/thread/KRMainThread.h" #include "libohos_render/utils/animate/KRAnimateOption.h" #include "libohos_render/utils/animate/KRAnimationUtils.h" -class KRAnimation; -class KRAnimationUserData { - public: - std::shared_ptr animation_; - explicit KRAnimationUserData(std::shared_ptr &animation) : animation_(animation) {} - ~KRAnimationUserData() { - animation_.reset(); - } -}; - class KRAnimation : public std::enable_shared_from_this { public: KRAnimation(const ArkUI_ContextHandle &context_handle, const std::shared_ptr &animate_option, @@ -73,15 +64,16 @@ class KRAnimation : public std::enable_shared_from_this { void SetCompleteCallback(const ArkUI_FinishCallbackType &complete_type, const std::function &complete) { complete_callback_ = complete; - std::shared_ptr self = shared_from_this(); - KRAnimationUserData *user_data = new KRAnimationUserData(self); + auto user_data = new std::weak_ptr(weak_from_this()); arkui_complete_callback_.type = complete_type; arkui_complete_callback_.userData = user_data; arkui_complete_callback_.callback = [](void *userData) { - KRAnimationUserData *animationUserData = (static_cast(userData)); - if (animationUserData && animationUserData->animation_) { - animationUserData->animation_->InvokeCompleteCallback(); - delete animationUserData; + auto weak = static_cast *>(userData); + if (weak) { + if (auto strong = weak->lock()) { + strong->InvokeCompleteCallback(); + } + delete weak; } }; } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt index 9c2fee87c..890988e6c 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt @@ -397,6 +397,7 @@ data class InputParams( val length: Int? = null, val syncRevision: Int? = null, val focusRequestId: Long? = null, + val focusIntentOnly: Boolean = false, ) data class KeyboardParams( @@ -461,7 +462,13 @@ class InputEvent : Event() { it as JSONObject val text = it.optString("text") val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } - handler(InputParams(text, focusRequestId = focusRequestId)) + handler( + InputParams( + text = text, + focusRequestId = focusRequestId, + focusIntentOnly = it.optBoolean("focusIntentOnly"), + ), + ) } } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt index f0ef865b0..88f7664cf 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextAreaView.kt @@ -724,7 +724,13 @@ open class TextAreaEvent : Event() { it as JSONObject val text = it.optString("text") val focusRequestId = it.optLong("focusRequestId").takeIf { id -> id > 0L } - handler(InputParams(text, focusRequestId = focusRequestId)) + handler( + InputParams( + text = text, + focusRequestId = focusRequestId, + focusIntentOnly = it.optBoolean("focusIntentOnly"), + ), + ) } } /** diff --git a/core/src/ohosArm64Main/kotlin/com/tencent/kuikly/core/utils/TypeUtils.kt b/core/src/ohosArm64Main/kotlin/com/tencent/kuikly/core/utils/TypeUtils.kt index 7665f7f30..d3de2575c 100644 --- a/core/src/ohosArm64Main/kotlin/com/tencent/kuikly/core/utils/TypeUtils.kt +++ b/core/src/ohosArm64Main/kotlin/com/tencent/kuikly/core/utils/TypeUtils.kt @@ -35,7 +35,19 @@ import platform.posix.int32_t @OptIn(ExperimentalForeignApi::class) fun Any?.toKRRenderCValue(memScope: MemScope, renderCValue: KRRenderCValue): KRRenderCValue { + // 优化:null 提前返回,避免走 8 次 instanceof 检查 + if (this == null) { + renderCValue.type = Type.NULL + renderCValue.value.intValue = 0 + return renderCValue + } when (this) { + is String -> { + with(memScope) { + renderCValue.type = Type.STRING + renderCValue.value.stringValue = this@toKRRenderCValue.cstr.ptr + } + } is Int -> { renderCValue.type = Type.INT renderCValue.value.intValue = this @@ -56,12 +68,6 @@ fun Any?.toKRRenderCValue(memScope: MemScope, renderCValue: KRRenderCValue): KRR renderCValue.type = Type.BOOL renderCValue.value.boolValue = if (this) 1 else 0 } - is String -> { - with(memScope) { - renderCValue.type = Type.STRING - renderCValue.value.stringValue = this@toKRRenderCValue.cstr.ptr - } - } is ByteArray -> { val bytes = this renderCValue.type = Type.BYTES @@ -123,8 +129,10 @@ private fun CPointer.arrayToAny(size: Int): Any { private fun KRRenderCValue.toByteArray(): Any { val size = size val byteArray = ByteArray(size) - for (index in 0 until size) { - byteArray[index] = value.bytesValue!![index] + if (size > 0) { + byteArray.usePinned { pinned -> + platform.posix.memcpy(pinned.addressOf(0), value.bytesValue, size.convert()) + } } return byteArray } diff --git a/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def b/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def index e04355e5a..f137b6954 100644 --- a/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def +++ b/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def @@ -335,8 +335,8 @@ long long com_tencent_kuikly_CurrentTimestamp() { typedef void (*CallKotlin)(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, KRRenderCValue arg2, KRRenderCValue arg3, KRRenderCValue arg4, KRRenderCValue arg5); extern int com_tencent_kuikly_SetCallKotlin(CallKotlin callKotlin); -extern const struct KRRenderCValue com_tencent_kuikly_CallNative(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, KRRenderCValue arg2, - KRRenderCValue arg3, KRRenderCValue arg4, KRRenderCValue arg5); +extern void com_tencent_kuikly_CallNative(int methodId, const KRRenderCValue *arg0, const KRRenderCValue *arg1, const KRRenderCValue *arg2, + const KRRenderCValue *arg3, const KRRenderCValue *arg4, const KRRenderCValue *arg5, KRRenderCValue *result); extern void com_tencent_kuikly_ScheduleContextTask(const char* pagerId, void (*onSchedule)(const char* pagerId)); extern bool com_tencent_kuikly_IsCurrentOnContextThread(const char* pagerId); diff --git a/core/src/ohosArm64Main/ohosInterop/include/KRRenderCValue.h b/core/src/ohosArm64Main/ohosInterop/include/KRRenderCValue.h index b32dff8b7..be4767200 100644 --- a/core/src/ohosArm64Main/ohosInterop/include/KRRenderCValue.h +++ b/core/src/ohosArm64Main/ohosInterop/include/KRRenderCValue.h @@ -41,7 +41,7 @@ typedef struct KRRenderCValue { //extern "C" { // kotlin interop tool does not recognize extern "C" syntax, commenting it out. typedef void (*CallKotlin)(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, KRRenderCValue arg2, KRRenderCValue arg3, KRRenderCValue arg4, KRRenderCValue arg5); extern int com_tencent_kuikly_SetCallKotlin(CallKotlin callKotlin); -extern const KRRenderCValue com_tencent_kuikly_CallNative(int methodId, KRRenderCValue arg0, KRRenderCValue arg1, KRRenderCValue arg2, - KRRenderCValue arg3, KRRenderCValue arg4, KRRenderCValue arg5); +extern void com_tencent_kuikly_CallNative(int methodId, const KRRenderCValue *arg0, const KRRenderCValue *arg1, const KRRenderCValue *arg2, + const KRRenderCValue *arg3, const KRRenderCValue *arg4, const KRRenderCValue *arg5, KRRenderCValue *result); //} #endif //MYAPPLICATION_KRRENDERCVALUE_H diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt index f72362cc7..8a422726c 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeAllSample.kt @@ -167,6 +167,7 @@ internal class ComposeAllSample : ComposeContainer() { DemoItem("重组性能分析", "RecompositionProfiler追踪重组热点", "RecompositionProfilerDemo"), DemoItem("TextFieldEmoji", "TextField 自定义表情示例(暂不支持鸿蒙)", "TextFieldEmojiDemo"), DemoItem("MoveableDrawer", "侧边栏组件示例(全屏/非全屏)", "MoveableDrawerDemo"), + DemoItem("iOS键盘InputTextField", "业务侧 InputTextField iOS 键盘复现", "IosKeyboardInputTextFieldDemo"), ) @Composable diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/IosKeyboardInputTextFieldDemo.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/IosKeyboardInputTextFieldDemo.kt new file mode 100644 index 000000000..ff011113c --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/IosKeyboardInputTextFieldDemo.kt @@ -0,0 +1,272 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.demo.pages.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import com.tencent.kuikly.compose.ComposeContainer +import com.tencent.kuikly.compose.extension.keyboardHeightChange +import com.tencent.kuikly.compose.extension.placeHolder +import com.tencent.kuikly.compose.foundation.background +import com.tencent.kuikly.compose.foundation.clickable +import com.tencent.kuikly.compose.foundation.layout.Arrangement +import com.tencent.kuikly.compose.foundation.layout.Box +import com.tencent.kuikly.compose.foundation.layout.Column +import com.tencent.kuikly.compose.foundation.layout.Row +import com.tencent.kuikly.compose.foundation.layout.Spacer +import com.tencent.kuikly.compose.foundation.layout.fillMaxSize +import com.tencent.kuikly.compose.foundation.layout.fillMaxWidth +import com.tencent.kuikly.compose.foundation.layout.height +import com.tencent.kuikly.compose.foundation.layout.padding +import com.tencent.kuikly.compose.foundation.layout.size +import com.tencent.kuikly.compose.foundation.shape.RoundedCornerShape +import com.tencent.kuikly.compose.foundation.text.BasicTextField +import com.tencent.kuikly.compose.foundation.text.KeyboardActions +import com.tencent.kuikly.compose.foundation.text.KeyboardOptions +import com.tencent.kuikly.compose.foundation.text.maxLength +import com.tencent.kuikly.compose.material3.Card +import com.tencent.kuikly.compose.material3.CardDefaults +import com.tencent.kuikly.compose.material3.Text +import com.tencent.kuikly.compose.setContent +import com.tencent.kuikly.compose.ui.Alignment +import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.focus.FocusRequester +import com.tencent.kuikly.compose.ui.focus.focusRequester +import com.tencent.kuikly.compose.ui.graphics.Brush +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.graphics.SolidColor +import com.tencent.kuikly.compose.ui.text.TextLayoutResult +import com.tencent.kuikly.compose.ui.text.TextRange +import com.tencent.kuikly.compose.ui.text.TextStyle +import com.tencent.kuikly.compose.ui.text.input.ImeAction +import com.tencent.kuikly.compose.ui.text.input.KeyboardType +import com.tencent.kuikly.compose.ui.text.input.TextFieldValue +import com.tencent.kuikly.compose.ui.unit.dp +import com.tencent.kuikly.compose.ui.unit.sp +import com.tencent.kuikly.core.annotations.Page +import com.tencent.kuikly.core.views.KeyboardParams +import com.tencent.kuikly.core.views.LengthLimitType +import kotlinx.coroutines.delay + +/** + * 业务侧 iOS 键盘问题复现页:集成业务提供的 [InputTextField] + [MeetingName] 组件写法。 + * + * 复现步骤:点击会议名称输入框 → 观察键盘弹出/收起、光标位置、清空按钮行为。 + */ +@Page("IosKeyboardInputTextFieldDemo") +internal class IosKeyboardInputTextFieldDemo : ComposeContainer() { + + override fun willInit() { + super.willInit() + setContent { + ComposeNavigationBar("iOS键盘InputTextField复现") { + Content() + } + } + } + + @Composable + private fun Content() { + var meetingName by remember { mutableStateOf("张三的测试会议") } + var keyboardHeight by remember { mutableStateOf(0f) } + var keyboardDuration by remember { mutableStateOf(0f) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "业务 InputTextField 复现", + fontSize = 18.sp, + color = Color.Black, + ) + Text( + text = "默认不传 autoFocusOnTextInputState:带预填文本进页不应自动弹键盘。", + fontSize = 13.sp, + color = Color(0xFF666666), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + MeetingName( + text = meetingName, + hint = "请输入会议名称", + onTextChange = { meetingName = it }, + keyboardHeightChange = { params -> + keyboardHeight = params.height + keyboardDuration = params.duration + println("[IosKeyboardInputTextFieldDemo] keyboard height=${params.height}, duration=${params.duration}") + }, + ) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFE3F2FD)), + ) { + Column(Modifier.padding(12.dp)) { + Text("键盘状态", fontSize = 14.sp, color = Color(0xFF1565C0)) + Spacer(Modifier.height(4.dp)) + Text("高度: ${keyboardHeight.toInt()} dp", fontSize = 13.sp, color = Color(0xFF424242)) + Text("动画时长: ${keyboardDuration.toInt()} ms", fontSize = 13.sp, color = Color(0xFF424242)) + Text("当前文本: $meetingName", fontSize = 13.sp, color = Color(0xFF424242)) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun InputTextField( + inputValue: String?, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = TextStyle.Default, + hint: String? = null, + hintColor: Color = Color(0xFF999999), + autoFocus: Boolean = false, + keyboardHeightChange: (KeyboardParams) -> Unit = {}, + cursorBrush: Brush = SolidColor(Color(0xFF1976D2)), + maxLines: Int = Int.MAX_VALUE, + maxLength: Int = Int.MAX_VALUE, + lengthLimitType: LengthLimitType = LengthLimitType.CHARACTER, + keyboardType: KeyboardType = KeyboardType.Text, + onTextLayout: (TextLayoutResult) -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val currentKeyboardHeightChange by rememberUpdatedState(keyboardHeightChange) + val currentOnValueChange by rememberUpdatedState(onValueChange) + val currentOnTextLayout by rememberUpdatedState(onTextLayout) + + val updatedModifier = modifier + .keyboardHeightChange(currentKeyboardHeightChange) + .focusRequester(focusRequester) + .maxLength(maxLength, type = lengthLimitType) + .let { + if (!hint.isNullOrEmpty()) { + it.placeHolder(hint, hintColor) + } else it + } + + val textFieldValueState = remember { + mutableStateOf( + TextFieldValue( + text = inputValue ?: "", + selection = TextRange(inputValue?.length ?: 0), + ), + ) + } + + val currentText = inputValue ?: "" + if (textFieldValueState.value.text != currentText) { + textFieldValueState.value = TextFieldValue( + text = currentText, + selection = TextRange(currentText.length), + ) + } + + BasicTextField( + modifier = updatedModifier, + value = textFieldValueState.value, + onValueChange = { newValue -> + textFieldValueState.value = newValue + currentOnValueChange(newValue) + }, + textStyle = textStyle, + maxLines = maxLines, + onTextLayout = currentOnTextLayout, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Default, + ), + keyboardActions = KeyboardActions(onAny = { + // 键盘右下角点击事件 + }), + cursorBrush = cursorBrush, + ) + + LaunchedEffect(autoFocus) { + if (autoFocus) { + delay(50) + focusRequester.requestFocus() + } + } +} + +@Composable +private fun MeetingName( + text: String?, + hint: String?, + onTextChange: (String) -> Unit, + keyboardHeightChange: (KeyboardParams) -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 6.dp), + ) { + InputTextField( + inputValue = text, + onValueChange = { newValue -> + onTextChange(newValue.text) + }, + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle( + color = Color(0xFF212121), + fontSize = 16.sp, + ), + hint = hint, + maxLength = 50, + keyboardHeightChange = keyboardHeightChange, + ) + } + + Box( + modifier = Modifier + .size(33.dp) + .padding(end = 11.dp) + .clickable { onTextChange("") }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "×", + fontSize = 22.sp, + color = Color(0xFF9E9E9E), + ) + } + } +} diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/catalog/ExampleIndexPage.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/catalog/ExampleIndexPage.kt index 1eedfecc8..6aad7824a 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/catalog/ExampleIndexPage.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/catalog/ExampleIndexPage.kt @@ -316,6 +316,13 @@ internal class ExampleIndexPage : BasePager() { declarativeExampleUrl = generateJumpUrl("VideoExamplePage") }) + itemList.add(ExampleItemData().apply { + avatarText = "St" + titleText = "Bridge Call 压力测试" + subtitleText = "循环10万次调用BridgeManager.callModuleMethod,测试Kotlin到Native桥接调用性能" + declarativeExampleUrl = generateJumpUrl("BridgeCallStressTestPage") + }) + // 仅在微信小程序平台展示 WX 组件 / API 示例 if (pageData.params.optString(IS_MINI_PROGRAM) == "1") { itemList.add(ExampleItemData().apply { From 45f45e40331716b8d1ce187afe78c71809fa8098 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 21:05:40 +0800 Subject: [PATCH 099/187] fix(ios): align inline box fill and border clipping (#27) Signed-off-by: Cindy Co-authored-by: Cindy --- core-render-ios/Extension/Vendor/KRLabel.m | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 9655e57c1..1308fd874 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -673,6 +673,16 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat centerY = CGRectGetMidY(bounds) + origin.y; CGFloat top = centerY - boxHeight / 2.0; CGFloat bottom = centerY + boxHeight / 2.0; + // TextKit clips background drawing to the current line fragment. Clamp + // the painted box before drawing either layer so the fill and border + // share one vertical edge. Clamping only the stroke leaves a visible + // fill strip below the bottom border when the nominal box is taller + // than the fragment. + CGFloat fragmentTop = CGRectGetMinY(lineRect) + origin.y; + CGFloat fragmentBottom = CGRectGetMaxY(lineRect) + origin.y; + top = MAX(top, fragmentTop); + bottom = MIN(bottom, fragmentBottom); + if (bottom <= top) return; CGRect rect = CGRectMake(left, top, right - left, bottom - top); UIColor *fill = style[@"backgroundColor"]; UIColor *border = style[@"borderColor"]; @@ -686,17 +696,6 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGContextSetStrokeColorWithColor(ctx, border.CGColor); CGContextSetLineWidth(ctx, borderWidth); CGRect strokeRect = CGRectInset(rect, borderWidth / 2.0, borderWidth / 2.0); - // TextKit clips background drawing to the current line fragment. - // When a whole group is pushed onto the next visual line, the - // nominal bottom edge can land exactly on that clip boundary and - // disappear. Keep the stroke center inside the drawable fragment; - // layout metrics and the fill rect remain unchanged. - CGFloat fragmentTop = CGRectGetMinY(lineRect) + origin.y; - CGFloat fragmentBottom = CGRectGetMaxY(lineRect) + origin.y; - CGFloat strokeTop = MAX(CGRectGetMinY(strokeRect), fragmentTop + borderWidth / 2.0); - CGFloat strokeBottom = MIN(CGRectGetMaxY(strokeRect), fragmentBottom - borderWidth / 2.0); - strokeRect.origin.y = strokeTop; - strokeRect.size.height = MAX(0, strokeBottom - strokeTop); UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect cornerRadius:MAX(0, radius - borderWidth / 2.0)]; [path stroke]; } From 41ad5a5999e27c6468f7bded90335a204d517bc0 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 21:08:42 +0800 Subject: [PATCH 100/187] fix(ios): center inline box chrome in line (#28) Signed-off-by: Cindy Co-authored-by: Cindy --- core-render-ios/Extension/Vendor/KRLabel.m | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 1308fd874..8c603111f 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -670,18 +670,18 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi if (boxHeight <= 0) { boxHeight = CGRectGetHeight(bounds) + paddingTop + paddingBottom + borderWidth * 2.0; } - CGFloat centerY = CGRectGetMidY(bounds) + origin.y; - CGFloat top = centerY - boxHeight / 2.0; - CGFloat bottom = centerY + boxHeight / 2.0; - // TextKit clips background drawing to the current line fragment. Clamp - // the painted box before drawing either layer so the fill and border - // share one vertical edge. Clamping only the stroke leaves a visible - // fill strip below the bottom border when the nominal box is taller - // than the fragment. CGFloat fragmentTop = CGRectGetMinY(lineRect) + origin.y; CGFloat fragmentBottom = CGRectGetMaxY(lineRect) + origin.y; - top = MAX(top, fragmentTop); - bottom = MIN(bottom, fragmentBottom); + CGFloat fragmentHeight = fragmentBottom - fragmentTop; + // Keep the intended box height whenever the line can contain it, but + // center the whole fill+border rect inside TextKit's drawable fragment. + // This preserves the chip height instead of trimming only its colored + // tail, while ensuring the border fully encloses the fill. Extremely + // short fragments fall back to their available height. + CGFloat paintedHeight = MIN(boxHeight, fragmentHeight); + CGFloat centerY = (fragmentTop + fragmentBottom) / 2.0; + CGFloat top = centerY - paintedHeight / 2.0; + CGFloat bottom = centerY + paintedHeight / 2.0; if (bottom <= top) return; CGRect rect = CGRectMake(left, top, right - left, bottom - top); UIColor *fill = style[@"backgroundColor"]; From 7ed15b7d21beab29531e98a87d208a77c9a34fff Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 21:41:38 +0800 Subject: [PATCH 101/187] fix(richtext): preserve inline box link typography (#30) Signed-off-by: Cindy Co-authored-by: Cindy --- .../foundation/text/KuiklyTextExtension.kt | 25 +++++++------ .../text/InlineBoxGroupLoweringTest.kt | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 8293d766d..d1616a63f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -472,6 +472,21 @@ internal fun RichTextAttr.applyAnnotatedString( this.pagerId = this@applyAnnotatedString.pagerId text(annoText.text.substring(start, end)) + val linkAnnotation = linkAnnotations + .firstOrNull { range -> !(end <= range.start || start >= range.end) } + + // Apply the link's base style before nested span ranges. Inline-box + // geometry belongs to the group, while typography/color/decoration + // still belongs to each child. Nested span styles retain their normal + // precedence over the link defaults. + linkAnnotation?.item?.styles?.style?.let { linkStyle -> + applySpanStyle( + linkStyle, + density, + includeInlineBox = inlineBoxRange == null, + ) + } + // Apply SpanStyle annoText.spanStyles .filter { range -> !(end <= range.start || start >= range.end) } @@ -506,17 +521,7 @@ internal fun RichTextAttr.applyAnnotatedString( } } - // Handle LinkAnnotation for current range - val linkAnnotation = linkAnnotations - .firstOrNull { range -> !(end <= range.start || start >= range.end) } - - // Apply LinkAnnotation styles if found linkAnnotation?.let { range -> - if (inlineBoxRange == null) { - val spanStyle = range.item.styles?.style ?: SpanStyle() - applySpanStyle(spanStyle, density) - } - // Add click event handler click { _ -> range.item.linkInteractionListener?.onClick(range.item) diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index 967094473..3f0840c21 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -31,6 +31,42 @@ import kotlin.test.assertIs class InlineBoxGroupLoweringTest { + @Test + fun inlineBoxLinkStyleStillAppliesChildTypography() { + val box = InlineBoxSpanStyle( + backgroundColor = Color.Yellow, + borderColor = Color.Black, + borderWidth = 1.dp, + paddingStart = 4.dp, + paddingEnd = 4.dp, + ) + val builder = AnnotatedString.Builder() + builder.withLink( + LinkAnnotation.Url( + url = "https://example.test/channel", + styles = TextLinkStyles( + style = SpanStyle( + fontWeight = FontWeight.Bold, + inlineBoxStyle = box, + ), + ), + ), + ) { + append("#channel") + } + + val attr = RichTextAttr() + attr.applyAnnotatedString( + annoText = builder.toAnnotatedString(), + density = Density(1f), + ) + + val group = assertIs(attr.getSpans().single()) + val child = assertIs(group.childrenForLayout().single()) + assertEquals("#channel", child.getText()) + assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) + } + @Test fun linkStyleRangeLowersToOneGroupWithStyledChildren() { val box = InlineBoxSpanStyle( From e845df56fe6c608d97bcca2c00ccf4beacabd14c Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 22:12:35 +0800 Subject: [PATCH 102/187] fix(richtext): isolate inline box child paint (#32) Signed-off-by: Cindy Co-authored-by: Cindy --- .../foundation/text/KuiklyTextExtension.kt | 28 ++++++++++++------- .../text/InlineBoxGroupLoweringTest.kt | 3 ++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index d1616a63f..8d0a355f0 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -475,16 +475,19 @@ internal fun RichTextAttr.applyAnnotatedString( val linkAnnotation = linkAnnotations .firstOrNull { range -> !(end <= range.start || start >= range.end) } - // Apply the link's base style before nested span ranges. Inline-box - // geometry belongs to the group, while typography/color/decoration - // still belongs to each child. Nested span styles retain their normal - // precedence over the link defaults. - linkAnnotation?.item?.styles?.style?.let { linkStyle -> - applySpanStyle( - linkStyle, - density, - includeInlineBox = inlineBoxRange == null, - ) + if (inlineBoxRange != null) { + // Geometry and background belong to the outer group. Children + // keep only link typography/foreground/decoration, then nested + // span ranges can override those defaults normally. + linkAnnotation?.item?.styles?.style?.let { linkStyle -> + applySpanStyle( + linkStyle.copy( + background = Color.Unspecified, + inlineBoxStyle = null, + ), + density, + ) + } } // Apply SpanStyle @@ -522,6 +525,11 @@ internal fun RichTextAttr.applyAnnotatedString( } linkAnnotation?.let { range -> + if (inlineBoxRange == null) { + val spanStyle = range.item.styles?.style ?: SpanStyle() + applySpanStyle(spanStyle, density) + } + // Add click event handler click { _ -> range.item.linkInteractionListener?.onClick(range.item) diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index 3f0840c21..018169ab0 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -18,6 +18,7 @@ import com.tencent.kuikly.compose.ui.text.withLink import com.tencent.kuikly.compose.ui.text.withStyle import com.tencent.kuikly.compose.ui.unit.Density import com.tencent.kuikly.compose.ui.unit.dp +import com.tencent.kuikly.core.base.Attr import com.tencent.kuikly.core.views.InlineBoxGroupSpan import com.tencent.kuikly.core.views.InlineBoxSpanStyle as CoreInlineBoxSpanStyle import com.tencent.kuikly.core.views.PlaceholderSpan @@ -46,6 +47,7 @@ class InlineBoxGroupLoweringTest { url = "https://example.test/channel", styles = TextLinkStyles( style = SpanStyle( + background = Color.Yellow, fontWeight = FontWeight.Bold, inlineBoxStyle = box, ), @@ -65,6 +67,7 @@ class InlineBoxGroupLoweringTest { val child = assertIs(group.childrenForLayout().single()) assertEquals("#channel", child.getText()) assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) + assertEquals(null, child.spanPropsMap()[Attr.StyleConst.BACKGROUND_COLOR]) } @Test From 3099998f8734c0aa403eb3abdaef3e387c062e5f Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 22:41:53 +0800 Subject: [PATCH 103/187] fix(richtext): preserve inline box link precedence (#33) Signed-off-by: Cindy Co-authored-by: Cindy --- .../foundation/text/KuiklyTextExtension.kt | 39 ++++++++++++------- .../text/InlineBoxGroupLoweringTest.kt | 38 ++++++++++++++++++ 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index 8d0a355f0..af02b768e 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -474,11 +474,23 @@ internal fun RichTextAttr.applyAnnotatedString( val linkAnnotation = linkAnnotations .firstOrNull { range -> !(end <= range.start || start >= range.end) } + val overlappingSpanStyles = annoText.spanStyles + .filter { range -> !(end <= range.start || start >= range.end) } if (inlineBoxRange != null) { - // Geometry and background belong to the outer group. Children - // keep only link typography/foreground/decoration, then nested - // span ranges can override those defaults normally. + // Paragraph/body spans commonly cover the whole link range. Apply + // those inherited defaults first so the link's typography remains + // authoritative, while strictly inner spans can still override it. + overlappingSpanStyles + .filter { range -> + range.start <= inlineBoxRange.start && range.end >= inlineBoxRange.end + } + .forEach { range -> + applySpanStyle(range.item, density, includeInlineBox = false) + } + + // Geometry and background belong to the outer group. Children keep + // only link typography/foreground/decoration. linkAnnotation?.item?.styles?.style?.let { linkStyle -> applySpanStyle( linkStyle.copy( @@ -488,18 +500,19 @@ internal fun RichTextAttr.applyAnnotatedString( density, ) } - } - // Apply SpanStyle - annoText.spanStyles - .filter { range -> !(end <= range.start || start >= range.end) } - .forEach { range -> - applySpanStyle( - range.item, - density, - includeInlineBox = inlineBoxRange == null, - ) + overlappingSpanStyles + .filter { range -> + range.start > inlineBoxRange.start || range.end < inlineBoxRange.end + } + .forEach { range -> + applySpanStyle(range.item, density, includeInlineBox = false) + } + } else { + overlappingSpanStyles.forEach { range -> + applySpanStyle(range.item, density) } + } if (slockInlineCodeAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCode() diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index 018169ab0..345f751b7 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -70,6 +70,44 @@ class InlineBoxGroupLoweringTest { assertEquals(null, child.spanPropsMap()[Attr.StyleConst.BACKGROUND_COLOR]) } + @Test + fun outerBodyStyleDoesNotOverrideInlineBoxLinkTypography() { + val box = InlineBoxSpanStyle( + backgroundColor = Color.Yellow, + borderColor = Color.Black, + borderWidth = 1.dp, + ) + val builder = AnnotatedString.Builder() + builder.withStyle(SpanStyle(fontWeight = FontWeight.Normal)) { + append("before ") + withLink( + LinkAnnotation.Url( + url = "https://example.test/channel", + styles = TextLinkStyles( + style = SpanStyle( + fontWeight = FontWeight.Bold, + inlineBoxStyle = box, + ), + ), + ), + ) { + append("#channel") + } + append(" after") + } + + val attr = RichTextAttr() + attr.applyAnnotatedString( + annoText = builder.toAnnotatedString(), + density = Density(1f), + ) + + val group = assertIs(attr.getSpans()[1]) + val child = assertIs(group.childrenForLayout().single()) + assertEquals("#channel", child.getText()) + assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) + } + @Test fun linkStyleRangeLowersToOneGroupWithStyledChildren() { val box = InlineBoxSpanStyle( From dd54e166446364e58f3f5ee42c89e4b9586584cf Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 22:52:11 +0800 Subject: [PATCH 104/187] test(richtext): assert inline box font family precedence (#34) Signed-off-by: Cindy Co-authored-by: Cindy --- .../foundation/text/InlineBoxGroupLoweringTest.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index 345f751b7..ddbd757e9 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -13,6 +13,7 @@ import com.tencent.kuikly.compose.ui.text.InlineBoxSpanStyle import com.tencent.kuikly.compose.ui.text.LinkAnnotation import com.tencent.kuikly.compose.ui.text.SpanStyle import com.tencent.kuikly.compose.ui.text.TextLinkStyles +import com.tencent.kuikly.compose.ui.text.font.FontFamily import com.tencent.kuikly.compose.ui.text.font.FontWeight import com.tencent.kuikly.compose.ui.text.withLink import com.tencent.kuikly.compose.ui.text.withStyle @@ -78,13 +79,19 @@ class InlineBoxGroupLoweringTest { borderWidth = 1.dp, ) val builder = AnnotatedString.Builder() - builder.withStyle(SpanStyle(fontWeight = FontWeight.Normal)) { + builder.withStyle( + SpanStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + ) + ) { append("before ") withLink( LinkAnnotation.Url( url = "https://example.test/channel", styles = TextLinkStyles( style = SpanStyle( + fontFamily = FontFamily.Serif, fontWeight = FontWeight.Bold, inlineBoxStyle = box, ), @@ -105,6 +112,7 @@ class InlineBoxGroupLoweringTest { val group = assertIs(attr.getSpans()[1]) val child = assertIs(group.childrenForLayout().single()) assertEquals("#channel", child.getText()) + assertEquals("serif", child.spanPropsMap()[TextConst.FONT_FAMILY]) assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) } From 61da1f638e2d8cc4a702f7777efff46948d1ece3 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sun, 12 Jul 2026 23:12:17 +0800 Subject: [PATCH 105/187] fix(android): draw one inline box chrome (#35) Signed-off-by: Cindy Co-authored-by: Cindy --- .../compose/foundation/text/KuiklyTextExtension.kt | 12 +++++++----- .../foundation/text/InlineBoxGroupLoweringTest.kt | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index af02b768e..b6f093a67 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -520,11 +520,13 @@ internal fun RichTextAttr.applyAnnotatedString( if (slockInlineCodeTrailingMarginAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCodeTrailingMargin() } - slockMarkdownTagChromeAnnotations - .firstOrNull { range -> start >= range.start && end <= range.end } - ?.item - ?.takeIf { it.isNotBlank() } - ?.let { kind -> slockMarkdownTagChrome(kind) } + if (inlineBoxRange == null) { + slockMarkdownTagChromeAnnotations + .firstOrNull { range -> start >= range.start && end <= range.end } + ?.item + ?.takeIf { it.isNotBlank() } + ?.let { kind -> slockMarkdownTagChrome(kind) } + } // Apply ParagraphStyle annoText.paragraphStyles diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index ddbd757e9..433245496 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -43,6 +43,7 @@ class InlineBoxGroupLoweringTest { paddingEnd = 4.dp, ) val builder = AnnotatedString.Builder() + builder.pushStringAnnotation("raft.build.markdown.tagChrome", "channel") builder.withLink( LinkAnnotation.Url( url = "https://example.test/channel", @@ -57,6 +58,7 @@ class InlineBoxGroupLoweringTest { ) { append("#channel") } + builder.pop() val attr = RichTextAttr() attr.applyAnnotatedString( @@ -69,6 +71,7 @@ class InlineBoxGroupLoweringTest { assertEquals("#channel", child.getText()) assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) assertEquals(null, child.spanPropsMap()[Attr.StyleConst.BACKGROUND_COLOR]) + assertEquals(null, child.spanPropsMap()[TextConst.SLOCK_MARKDOWN_TAG_CHROME]) } @Test From e30a26da00c2c686eb1cd21c2a1722aa09b962be Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 01:30:03 +0800 Subject: [PATCH 106/187] fix(richtext): remove migrated markdown tag chrome (#36) Signed-off-by: Android-Developer-3 Co-authored-by: Android-Developer-3 --- .../foundation/text/KuiklyTextExtension.kt | 16 -- .../text/InlineBoxGroupLoweringTest.kt | 3 - .../component/text/KRRichTextBuilder.kt | 71 ----- .../component/text/KRRichTextViewDrawer.kt | 120 --------- .../text/KRSlockMarkdownTagChromeTest.kt | 21 -- .../Extension/AdvancedComps/KRRichTextView.m | 250 +----------------- core-render-ios/Extension/Vendor/KRLabel.m | 128 ++------- .../components/richtext/KRRichTextShadow.cpp | 55 ++-- .../tencent/kuikly/core/views/RichTextView.kt | 7 - .../com/tencent/kuikly/core/views/TextView.kt | 1 - 10 files changed, 44 insertions(+), 628 deletions(-) delete mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt index b6f093a67..34daf7d64 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/KuiklyTextExtension.kt @@ -58,7 +58,6 @@ import com.tencent.kuikly.core.views.TextSpan private const val SLOCK_INLINE_CODE_ANNOTATION_TAG = "raft.build.markdown.inlineCode" private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_ANNOTATION_TAG = "raft.build.markdown.inlineCodeTrailingMargin" -private const val SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG = "raft.build.markdown.tagChrome" // Returns platform-specific default font size private fun TextAttr.defaultFontSize(): Float { @@ -388,13 +387,6 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.start) positions.add(range.end) } - val slockMarkdownTagChromeAnnotations = - annoText.getStringAnnotations(SLOCK_MARKDOWN_TAG_CHROME_ANNOTATION_TAG, 0, annoText.length) - slockMarkdownTagChromeAnnotations.forEach { range -> - positions.add(range.start) - positions.add(range.end) - } - // Collect placeholder info and positions val (placeholders, _) = if (annoText.hasInlineContent()) { annoText.resolveInlineContent(inlineContent) @@ -520,14 +512,6 @@ internal fun RichTextAttr.applyAnnotatedString( if (slockInlineCodeTrailingMarginAnnotations.any { range -> start >= range.start && end <= range.end }) { slockInlineCodeTrailingMargin() } - if (inlineBoxRange == null) { - slockMarkdownTagChromeAnnotations - .firstOrNull { range -> start >= range.start && end <= range.end } - ?.item - ?.takeIf { it.isNotBlank() } - ?.let { kind -> slockMarkdownTagChrome(kind) } - } - // Apply ParagraphStyle annoText.paragraphStyles .filter { range -> !(end <= range.start || start >= range.end) } diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt index 433245496..ddbd757e9 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -43,7 +43,6 @@ class InlineBoxGroupLoweringTest { paddingEnd = 4.dp, ) val builder = AnnotatedString.Builder() - builder.pushStringAnnotation("raft.build.markdown.tagChrome", "channel") builder.withLink( LinkAnnotation.Url( url = "https://example.test/channel", @@ -58,7 +57,6 @@ class InlineBoxGroupLoweringTest { ) { append("#channel") } - builder.pop() val attr = RichTextAttr() attr.applyAnnotatedString( @@ -71,7 +69,6 @@ class InlineBoxGroupLoweringTest { assertEquals("#channel", child.getText()) assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) assertEquals(null, child.spanPropsMap()[Attr.StyleConst.BACKGROUND_COLOR]) - assertEquals(null, child.spanPropsMap()[TextConst.SLOCK_MARKDOWN_TAG_CHROME]) } @Test diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index 7a887fc65..dbb793387 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -208,7 +208,6 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { textSpans.add(ForegroundColorSpan(spanProps.color)) if (spanProps.backgroundColor != Color.TRANSPARENT && !spanProps.slockInlineCode && - spanProps.slockMarkdownTagChrome == null && spanProps.inlineBoxStyle == null ) { textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) @@ -241,11 +240,6 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.slockInlineCodeTrailingMargin) { textSpans.add(KRSlockInlineCodeTrailingMarginSpan()) } - if (spanProps.inlineBoxStyle == null) { - spanProps.slockMarkdownTagChrome?.let { kind -> - textSpans.add(KRSlockMarkdownTagSpan(kind)) - } - } spanProps.inlineBoxStyle?.let { style -> textSpans.add(KRInlineBoxSpan(style)) } @@ -295,13 +289,6 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps is TextSpanProps && spanProps.slockInlineCode) { applySlockInlineCodeAtomicTextSpans(spanStart, spanEnd) } - if ( - spanProps is TextSpanProps && - spanProps.inlineBoxStyle == null && - spanProps.slockMarkdownTagChrome.isSlockMarkdownTagChipChrome() - ) { - applySlockMarkdownTagAtomicTextSpan(spanStart, spanEnd) - } if (spanProps is TextSpanProps && spanProps.inlineBoxStyle != null) { applyInlineBoxAtomicTextSpan(spanStart, spanEnd, spanProps.inlineBoxStyle) } @@ -403,7 +390,6 @@ class TextSpanProps( val backgroundColor: Int val slockInlineCode: Boolean val slockInlineCodeTrailingMargin: Boolean - val slockMarkdownTagChrome: String? val inlineBoxStyle: KRInlineBoxSpanStyle? var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -474,9 +460,6 @@ class TextSpanProps( spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE, false) slockInlineCodeTrailingMargin = spanValue.optInt(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, 0) == 1 || spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, false) - slockMarkdownTagChrome = - spanValue.optString(TextConst.SLOCK_MARKDOWN_TAG_CHROME, "") - .takeIf { it.isNotEmpty() } inlineBoxStyle = KRInlineBoxSpanStyle.from(spanValue, kuiklyContext) val textShadowStr = spanValue.optString(KRTextProps.PROP_KEY_TEXT_SHADOW, "") textShadow = BoxShadow(textShadowStr, kuiklyContext) @@ -532,7 +515,6 @@ class InlineBoxGroupSpanProps( } class KRSlockInlineCodeSpan -class KRSlockMarkdownTagSpan(val kind: String) data class KRInlineBoxSpanStyle( val backgroundColor: Int?, @@ -625,11 +607,6 @@ private class KRInlineBoxEdgeAdvanceSpan( } } -internal const val SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION = "ordinaryMention" - -internal fun String?.isSlockMarkdownTagChipChrome(): Boolean = - this != null && this != SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION - private class KRSlockInlineCodeTrailingMarginSpan : ReplacementSpan() { override fun getSize( @@ -704,17 +681,6 @@ private class KRCustomUnderlineSpan( } } -private fun SpannableStringBuilder.applySlockMarkdownTagAtomicTextSpan(start: Int, end: Int) { - if (start < end) { - setSpan( - KRSlockMarkdownTagAtomicTextSpan(), - start, - end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } -} - private fun SpannableStringBuilder.applyInlineBoxAtomicTextSpan( start: Int, end: Int, @@ -770,43 +736,6 @@ private class KRInlineBoxAtomicTextSpan( } } -private class KRSlockMarkdownTagAtomicTextSpan : ReplacementSpan() { - - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int = if (text == null || start >= end) { - 0 - } else { - val textWidth = paint.measureText(text, start, end) - val strokePadding = max(1f, paint.strokeWidth * 2f) - ceil((textWidth + strokePadding + edgePadding(paint) * 2f).toDouble()).toInt() - } - - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text != null && start < end) { - canvas.drawText(text, start, end, x + edgePadding(paint), y.toFloat(), paint) - } - } - - private fun edgePadding(paint: Paint): Float { - return paint.textSize * (SLOCK_INLINE_CODE_EDGE_PADDING_RATIO + SLOCK_INLINE_CODE_EDGE_MARGIN_RATIO) - } -} - private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { var index = start var firstAtom = true diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index 1b95bd638..b02d5e194 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -47,15 +47,6 @@ private const val SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO = 2f / 15f private const val SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO = 24f / 15f private const val SLOCK_INLINE_CODE_BORDER_WIDTH_DP = 1f private const val SLOCK_INLINE_CODE_BORDER_MIN_WIDTH = 2f -private const val SLOCK_MARKDOWN_TAG_KIND_CHANNEL = "channel" -private const val SLOCK_MARKDOWN_TAG_KIND_THREAD = "thread" -private const val SLOCK_MARKDOWN_TAG_KIND_TASK = "task" -private const val SLOCK_MARKDOWN_TAG_KIND_SELF_MENTION = "selfMention" -private const val SLOCK_MARKDOWN_TAG_KIND_ACTIVE = "active" -private const val SLOCK_MARKDOWN_TAG_CHANNEL_FILL_COLOR = 0x4DFE7DA8 -private const val SLOCK_MARKDOWN_TAG_THREAD_FILL_COLOR = 0x4D27CCF3 -private const val SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR = 0x66FFD440 -private const val SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR = 0xFFFFD440.toInt() /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -80,15 +71,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { isAntiAlias = false } private val slockInlineCodeRect = RectF() - private val slockMarkdownTagFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - } - private val slockMarkdownTagBorderPaint = Paint().apply { - style = Paint.Style.FILL - color = SLOCK_INLINE_CODE_BORDER_COLOR - isAntiAlias = false - } - private val slockMarkdownTagRect = RectF() private val inlineBoxFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } @@ -121,10 +103,8 @@ class KRRichTextViewDrawer(val textLayout: Layout) { fun draw(canvas: Canvas) { drawInlineBoxChrome(canvas, drawFill = true, drawBorder = false) drawSlockInlineCodeChrome(canvas, drawFill = true, drawBorder = false) - drawSlockMarkdownTagChrome(canvas, drawFill = true, drawBorder = false) textLayout.draw(canvas) drawSlockInlineCodeChrome(canvas, drawFill = false, drawBorder = true) - drawSlockMarkdownTagChrome(canvas, drawFill = false, drawBorder = true) drawInlineBoxChrome(canvas, drawFill = false, drawBorder = true) } @@ -202,84 +182,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } } - private fun drawSlockMarkdownTagChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { - val spanned = textLayout.text as? Spanned ?: return - val spans = spanned.getSpans(0, spanned.length, KRSlockMarkdownTagSpan::class.java) - if (spans.isEmpty()) return - - val paint = textLayout.paint - val horizontalPadding = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_PADDING_RATIO - val horizontalMargin = paint.textSize * SLOCK_INLINE_CODE_HORIZONTAL_MARGIN_RATIO - val verticalPadding = paint.textSize * SLOCK_INLINE_CODE_VERTICAL_PADDING_RATIO - val minHeight = paint.textSize * SLOCK_INLINE_CODE_MIN_HEIGHT_RATIO - val fontMetrics = paint.fontMetrics - val layoutLeft = 0f - val layoutRight = textLayout.width.toFloat() - - spans.forEach { span -> - if (!span.kind.isSlockMarkdownTagChipChrome()) return@forEach - val start = spanned.getSpanStart(span) - val end = spanned.getSpanEnd(span) - if (start < 0 || end <= start) return@forEach - - if (drawFill) { - slockMarkdownTagFillPaint.color = span.kind.slockMarkdownTagFillColor() - } - val startLine = textLayout.getLineForOffset(start) - val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) - for (line in startLine..endLine) { - val lineStart = textLayout.getLineStart(line) - val lineVisibleEnd = textLayout.slockInlineCodeVisibleEnd(line) - val segmentStart = max(start, lineStart) - val segmentEnd = min(end, lineVisibleEnd) - if (segmentEnd <= segmentStart) continue - - val startX = - if (segmentStart <= lineStart) { - layoutLeft - } else { - textLayout.getPrimaryHorizontal(segmentStart) - } - val endX = - if (segmentEnd >= lineVisibleEnd) { - textLayout.getLineRight(line) - } else { - textLayout.getPrimaryHorizontal(segmentEnd) - } - val segmentLeft = min(startX, endX) - val segmentRight = max(startX, endX) - val left = if (segmentStart == start) { - segmentLeft + horizontalMargin - } else { - segmentLeft - horizontalPadding - }.coerceAtLeast(layoutLeft) - val right = if (segmentEnd == end) { - segmentRight - horizontalMargin - } else { - segmentRight + horizontalPadding - }.coerceAtMost(layoutRight) - if (right <= left) continue - - val baseline = textLayout.getLineBaseline(line).toFloat() - val textTop = baseline + fontMetrics.ascent - verticalPadding - val textBottom = baseline + fontMetrics.descent + verticalPadding - val height = max(textBottom - textTop, minHeight) - val centerY = (textTop + textBottom) / 2f - val top = centerY - height / 2f - val bottom = centerY + height / 2f - if (bottom <= top) continue - - slockMarkdownTagRect.set(left, top, right, bottom) - if (drawFill) { - canvas.drawRect(slockMarkdownTagRect, slockMarkdownTagFillPaint) - } - if (drawBorder) { - canvas.drawSlockMarkdownTagBorder(left, top, right, bottom) - } - } - } - } - private fun drawSlockInlineCodeChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { val spanned = textLayout.text as? Spanned ?: return val spans = spanned.getSpans(0, spanned.length, KRSlockInlineCodeSpan::class.java) @@ -382,28 +284,6 @@ class KRRichTextViewDrawer(val textLayout: Layout) { drawRect(borderRight - borderWidth, borderTop, borderRight, borderBottom, slockInlineCodeBorderPaint) } - private fun Canvas.drawSlockMarkdownTagBorder(left: Float, top: Float, right: Float, bottom: Float) { - val borderWidth = slockChipBorderWidthPx - val borderLeft = floor(left) - val borderTop = floor(top) - val borderRight = ceil(right) - val borderBottom = ceil(bottom) - drawRect(borderLeft, borderTop, borderRight, borderTop + borderWidth, slockMarkdownTagBorderPaint) - drawRect(borderLeft, borderBottom - borderWidth, borderRight, borderBottom, slockMarkdownTagBorderPaint) - drawRect(borderLeft, borderTop, borderLeft + borderWidth, borderBottom, slockMarkdownTagBorderPaint) - drawRect(borderRight - borderWidth, borderTop, borderRight, borderBottom, slockMarkdownTagBorderPaint) - } - - private fun String.slockMarkdownTagFillColor(): Int = - when (this) { - SLOCK_MARKDOWN_TAG_KIND_CHANNEL -> SLOCK_MARKDOWN_TAG_CHANNEL_FILL_COLOR - SLOCK_MARKDOWN_TAG_KIND_THREAD -> SLOCK_MARKDOWN_TAG_THREAD_FILL_COLOR - SLOCK_MARKDOWN_TAG_KIND_SELF_MENTION -> SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR - SLOCK_MARKDOWN_TAG_KIND_ACTIVE -> SLOCK_MARKDOWN_TAG_SELF_MENTION_FILL_COLOR - SLOCK_MARKDOWN_TAG_KIND_TASK -> SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR - else -> SLOCK_MARKDOWN_TAG_TASK_FILL_COLOR - } - private fun Layout.slockInlineCodeVisibleEnd(line: Int): Int { val lineStart = getLineStart(line) val ellipsisCount = getEllipsisCount(line) diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt deleted file mode 100644 index 5cc8782aa..000000000 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRSlockMarkdownTagChromeTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tencent.kuikly.core.render.android.expand.component.text - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class KRSlockMarkdownTagChromeTest { - - @Test - fun ordinaryMentionUsesTextUnderlineInsteadOfAtomicChipChrome() { - assertFalse(SLOCK_MARKDOWN_TAG_KIND_ORDINARY_MENTION.isSlockMarkdownTagChipChrome()) - assertFalse((null as String?).isSlockMarkdownTagChipChrome()) - } - - @Test - fun actualChipKindsKeepAtomicLayoutAndPaintChrome() { - listOf("channel", "thread", "task", "selfMention", "active").forEach { kind -> - assertTrue(kind, kind.isSlockMarkdownTagChipChrome()) - } - } -} diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index f94e70c74..c2aa6323d 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -25,126 +25,11 @@ NSString *const kGradientInfoKeyFont = @"font"; NSString *const kGradientInfoKeyGlobalRange = @"globalRange"; -static const CGFloat kKRSlockAtomicChipHorizontalPaddingRatio = 4.0 / 15.0; -static const CGFloat kKRSlockAtomicChipHorizontalMarginRatio = 2.0 / 15.0; -static const CGFloat kKRSlockAtomicChipLineHeightRatio = 1.5; -static const CGFloat kKRSlockAtomicChipBorderWidth = 1.0; +static const CGFloat kKRSlockInlineCodeHorizontalPaddingRatio = 4.0 / 15.0; +static const CGFloat kKRSlockInlineCodeHorizontalMarginRatio = 2.0 / 15.0; +static const CGFloat kKRSlockInlineCodeLineHeightRatio = 1.5; static const NSUInteger kKRSlockInlineCodeAtomizeThreshold = 16; -static UIColor *KRSlockAtomicChipFillColor(NSString *chrome, UIColor *resolvedStyleFill) { - if (resolvedStyleFill && CGColorGetAlpha(resolvedStyleFill.CGColor) > 0) { - return resolvedStyleFill; - } - uint32_t argb = 0; - if ([chrome isEqualToString:@"channel"]) { - argb = 0x4DFE7DA8; - } else if ([chrome isEqualToString:@"thread"]) { - argb = 0x4D27CCF3; - } else if ([chrome isEqualToString:@"task"]) { - argb = 0x66FFD440; - } else if ([chrome isEqualToString:@"selfMention"] || [chrome isEqualToString:@"active"]) { - argb = 0xFFFFD440; - } - CGFloat a = ((argb >> 24) & 0xFF) / 255.0; - CGFloat r = ((argb >> 16) & 0xFF) / 255.0; - CGFloat g = ((argb >> 8) & 0xFF) / 255.0; - CGFloat b = (argb & 0xFF) / 255.0; - return [UIColor colorWithRed:r green:g blue:b alpha:a]; -} - -// TextKit has no native inline-box model for an attributed-string subrange. A -// Slock reference chip is therefore represented as one attachment whose bounds -// are the complete inline box (text + padding + transparent outer margin). This -// keeps measurement, wrapping and drawing on the same atomic layout object, -// matching Android's ReplacementSpan instead of painting outside glyph advance. -@interface KRSlockAtomicChipAttachment : NSTextAttachment - -@property (nonatomic, copy) NSString *originalText; - -- (instancetype)initWithText:(NSString *)text - font:(UIFont *)font - textColor:(UIColor *)textColor - fillColor:(UIColor *)fillColor - letterSpacing:(CGFloat)letterSpacing; - -@end - -@implementation KRSlockAtomicChipAttachment - -- (instancetype)initWithText:(NSString *)text - font:(UIFont *)font - textColor:(UIColor *)textColor - fillColor:(UIColor *)fillColor - letterSpacing:(CGFloat)letterSpacing { - if (self = [super init]) { - _originalText = [text copy] ?: @""; - UIFont *resolvedFont = font ?: [UIFont systemFontOfSize:15.0]; - UIColor *resolvedTextColor = textColor ?: [UIColor blackColor]; - UIColor *resolvedFillColor = fillColor ?: [UIColor clearColor]; - NSMutableDictionary *attributes = [@{ - NSFontAttributeName: resolvedFont, - NSForegroundColorAttributeName: resolvedTextColor, - } mutableCopy]; - if (letterSpacing != 0) { - attributes[NSKernAttributeName] = @(letterSpacing); - } - NSAttributedString *displayText = [[NSAttributedString alloc] initWithString:_originalText attributes:attributes]; - CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)displayText); - CGFloat ascent = 0; - CGFloat descent = 0; - CGFloat leading = 0; - CGFloat textWidth = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); - CGFloat textSize = resolvedFont.pointSize; - CGFloat innerPadding = textSize * kKRSlockAtomicChipHorizontalPaddingRatio; - CGFloat outerMargin = textSize * kKRSlockAtomicChipHorizontalMarginRatio; - CGFloat edgeAdvance = innerPadding + outerMargin; - CGFloat chipHeight = textSize * kKRSlockAtomicChipLineHeightRatio; - CGFloat totalWidth = textWidth + edgeAdvance * 2.0; - - UIGraphicsBeginImageContextWithOptions(CGSizeMake(totalWidth, chipHeight), NO, 0.0); - CGContextRef context = UIGraphicsGetCurrentContext(); - if (context) { - CGRect chromeRect = CGRectMake(outerMargin, 0, totalWidth - outerMargin * 2.0, chipHeight); - CGContextSetFillColorWithColor(context, resolvedFillColor.CGColor); - CGContextFillRect(context, chromeRect); - - CGFloat borderWidth = kKRSlockAtomicChipBorderWidth; - CGFloat left = CGRectGetMinX(chromeRect); - CGFloat top = CGRectGetMinY(chromeRect); - CGFloat right = CGRectGetMaxX(chromeRect); - CGFloat bottom = CGRectGetMaxY(chromeRect); - CGContextSetFillColorWithColor(context, [UIColor blackColor].CGColor); - CGContextFillRect(context, CGRectMake(left, top, right - left, borderWidth)); - CGContextFillRect(context, CGRectMake(left, bottom - borderWidth, right - left, borderWidth)); - CGContextFillRect(context, CGRectMake(left, top, borderWidth, bottom - top)); - CGContextFillRect(context, CGRectMake(right - borderWidth, top, borderWidth, bottom - top)); - - CGContextSaveGState(context); - CGContextTranslateCTM(context, 0, chipHeight); - CGContextScaleCTM(context, 1.0, -1.0); - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGFloat baseline = (chipHeight - ascent + descent) / 2.0; - CGContextSetTextPosition(context, edgeAdvance, baseline); - CTLineDraw(line, context); - CGContextRestoreGState(context); - } - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - CFRelease(line); - - self.image = image; - CGFloat baselineOffset = (resolvedFont.ascender + resolvedFont.descender) / 2.0 - chipHeight / 2.0; - self.bounds = CGRectMake(0, baselineOffset, totalWidth, chipHeight); - } - return self; -} - -- (NSString *)kr_originlTextBeforeTextAttachment { - return self.originalText ?: @""; -} - -@end - @interface KRInlineBoxAttachment : NSTextAttachment @property (nonatomic, copy) NSString *originalText; @@ -334,12 +219,12 @@ - (instancetype)initWithText:(NSString *)text CGFloat leading = 0; CGFloat textWidth = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); CGFloat textSize = resolvedFont.pointSize; - CGFloat innerPadding = textSize * kKRSlockAtomicChipHorizontalPaddingRatio; - CGFloat outerMargin = textSize * kKRSlockAtomicChipHorizontalMarginRatio; + CGFloat innerPadding = textSize * kKRSlockInlineCodeHorizontalPaddingRatio; + CGFloat outerMargin = textSize * kKRSlockInlineCodeHorizontalMarginRatio; CGFloat edgeAdvance = innerPadding + outerMargin; CGFloat leadingAdvance = leadingEdge ? edgeAdvance : 0.0; CGFloat trailingAdvance = trailingEdge ? edgeAdvance : 0.0; - CGFloat atomHeight = textSize * kKRSlockAtomicChipLineHeightRatio; + CGFloat atomHeight = textSize * kKRSlockInlineCodeLineHeightRatio; CGFloat totalWidth = textWidth + leadingAdvance + trailingAdvance; UIGraphicsBeginImageContextWithOptions(CGSizeMake(totalWidth, atomHeight), NO, 0.0); @@ -379,15 +264,6 @@ - (BOOL)kr_slockInlineCodeTrailingEdge { @end -static BOOL KRSlockUsesAtomicChipBox(NSString *chrome) { - // Reference chips are indivisible inline boxes. Inline code is handled by - // KRSlockInlineCodeAtomAttachment instead: one box for short spans and a - // grapheme box chain for long spans, preserving #58 character wrapping. - return chrome.length > 0 && - ![chrome isEqualToString:@"ordinaryMention"] && - ![chrome isEqualToString:@"inlineCode"]; -} - @interface KRRichTextView() @property (nonatomic, strong) NSNumber *css_numberOfLines; @@ -766,14 +642,7 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.strokeWidth = strokeWidth; spanAttrs.shadow = textShadow; spanAttrs.richAttrArray = richAttrArray; - // Slock rich-text chip chrome (task #439): a tag chip carries its chrome kind - // in "slockMarkdownTagChrome" (SLOCK_MARKDOWN_TAG_CHROME); inline code carries - // "slockInlineCode" (SLOCK_INLINE_CODE). Normalize both to a chrome-kind string - // that KRLayoutManager maps to a fill/border. - id slockTagChrome = propStyle[@"slockMarkdownTagChrome"]; - if ([slockTagChrome isKindOfClass:[NSString class]] && [slockTagChrome length]) { - spanAttrs.slockChrome = slockTagChrome; - } else if (propStyle[@"slockInlineCode"]) { + if (propStyle[@"slockInlineCode"]) { spanAttrs.slockChrome = @"inlineCode"; } BOOL hasInlineBoxStyle = propStyle[@"inlineBoxBackgroundColor"] || @@ -821,7 +690,6 @@ - (NSMutableAttributedString *)p_buildAttributedString { resAttr = [[KuiklyRenderBridge componentExpandHandler] hr_customTextWithAttributedString:resAttr textPostProcessor:textPostProcessor]; } } - [self p_reserveSlockChipBoxAdvance:resAttr]; return resAttr; } @@ -962,57 +830,6 @@ - (NSMutableAttributedString *)p_createInlineBoxGroupAttributedStringWithSpan:(N return group; } -// task #439 ⑥: reserve the chip's inline-box advance (px-1 padding + 1px border) in -// LAYOUT via kern, so neighbors are pushed outside the box like React's inline-block -// (border→neighbor keeps a ~1-space gap) instead of laying out into the painted -// fill/border region (which made chips look glued to adjacent text, margin≈0). -// Leading reserve goes on the char BEFORE the run; trailing on the run's last char. -// The trailing kern inflates the run's boundingRect — KRLayoutManager accounts for it. -- (void)p_reserveSlockChipBoxAdvance:(NSMutableAttributedString *)str { - if (str.length == 0) { - return; - } - NSMutableArray *chipRanges = [NSMutableArray new]; - [str enumerateAttribute:KRSlockChromeAttributeName - inRange:NSMakeRange(0, str.length) - options:0 - usingBlock:^(id value, NSRange r, BOOL *stop) { - if ([value isKindOfClass:[NSString class]] && [(NSString *)value length] > 0 - && ![(NSString *)value isEqualToString:@"ordinaryMention"]) { - [chipRanges addObject:[NSValue valueWithRange:r]]; - } - }]; - for (NSValue *rv in chipRanges) { - NSRange r = rv.rangeValue; - NSString *chrome = [str attribute:KRSlockChromeAttributeName atIndex:r.location effectiveRange:NULL]; - if ([chrome isEqualToString:@"inlineCode"]) { - // The first/last atom bounds own inner padding + transparent outer - // margin. Never add a second kern reserve to the chain. - continue; - } - UIFont *font = [str attribute:NSFontAttributeName atIndex:r.location effectiveRange:NULL]; - CGFloat textSize = font ? font.pointSize : 15.0; - // box reserve = px-1 (4/15·textSize) + 1px border (mirror KRLabel.m). - // TRAILING only: reserve the box's right region so the next token (e.g. a comma - // with no source space) is pushed to the box edge, giving right-side inner - // padding. XiShi calibration (a510610f): a LEADING kern over-added the left - // external gap (17px) — the left gap should come from the source space alone, so - // no leading kern; the left inner padding is drawn by KRLayoutManager into the - // source space. - CGFloat boxReserve = textSize * (4.0 / 15.0) + 1.0; - [self p_addKern:boxReserve toString:str atIndex:NSMaxRange(r) - 1]; // trailing only - } -} - -- (void)p_addKern:(CGFloat)delta toString:(NSMutableAttributedString *)str atIndex:(NSUInteger)idx { - if (idx >= str.length) { - return; - } - NSNumber *existing = [str attribute:NSKernAttributeName atIndex:idx effectiveRange:NULL]; - CGFloat base = [existing isKindOfClass:[NSNumber class]] ? existing.doubleValue : 0.0; - [str addAttribute:NSKernAttributeName value:@(base + delta) range:NSMakeRange(idx, 1)]; -} - - (nullable NSMutableAttributedString *)p_createSlockInlineCodeAtomChainWithAttributes:(KRSpanAttributes *)attrs { if (attrs.text.length == 0) { return nil; @@ -1098,40 +915,6 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut if ([attrs.slockChrome isEqualToString:@"inlineCode"] && attrs.text.length > 0) { return [self p_createSlockInlineCodeAtomChainWithAttributes:attrs]; } - if (KRSlockUsesAtomicChipBox(attrs.slockChrome) && attrs.text.length > 0) { - KRSlockAtomicChipAttachment *attachment = [[KRSlockAtomicChipAttachment alloc] - initWithText:attrs.text - font:attrs.font - textColor:attrs.color - fillColor:KRSlockAtomicChipFillColor(attrs.slockChrome, attrs.backgroundColor) - letterSpacing:attrs.letterSpacing]; - NSMutableAttributedString *atomicBox = [[NSMutableAttributedString alloc] - initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; - NSRange atomicRange = NSMakeRange(0, atomicBox.length); - [atomicBox addAttribute:NSWritingDirectionAttributeName - value:@[@((NSInteger)NSWritingDirectionLeftToRight | (NSInteger)NSWritingDirectionOverride)] - range:atomicRange]; - [atomicBox addAttribute:NSFontAttributeName value:attrs.font ?: [UIFont systemFontOfSize:15.0] range:atomicRange]; - [atomicBox addAttribute:KuiklyIndexAttributeName value:@(attrs.spanIndex) range:atomicRange]; - NSLog(@"SLOCK_TASK448_ATOMIC kind=%@ text=\"%@\" bounds={%.2f,%.2f,%.2f,%.2f} spanIndex=%ld", - attrs.slockChrome, - attrs.text, - attachment.bounds.origin.x, - attachment.bounds.origin.y, - attachment.bounds.size.width, - attachment.bounds.size.height, - (long)attrs.spanIndex); - [self p_applyTextAttributeWithAttr:atomicBox - textAliment:attrs.textAlign - lineSpacing:attrs.lineSpacing - paragraphSpacing:attrs.paragraphSpacing - lineHeight:attrs.lineHeight - range:atomicRange - fontSize:attrs.font.pointSize - headIndent:attrs.headIndent - font:attrs.font ?: [UIFont systemFontOfSize:15.0]]; - return atomicBox; - } NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:attrs.text attributes:@{}]; NSRange range = NSMakeRange(0, attributedString.length); @@ -1166,26 +949,11 @@ - (nullable NSMutableAttributedString *)p_createSpanAttributedStringWithAttribut [attributedString addAttribute:NSKernAttributeName value:@(attrs.letterSpacing) range:range]; } - if (attrs.backgroundColor && attrs.slockChrome.length == 0) { - // When this span is a Slock chip (task #439), the padded/bordered chip fill - // is drawn by KRLayoutManager; skip the tight NSBackgroundColorAttributeName - // rect so the chip is the single fill source (avoids double-fill on selfMention). + if (attrs.backgroundColor) { [attributedString addAttribute:NSBackgroundColorAttributeName value:attrs.backgroundColor range:range]; } - // Slock rich-text chip chrome (task #439): tag the range so KRLayoutManager draws - // the bordered chip that a plain background attribute cannot express. - if (attrs.slockChrome.length) { - [attributedString addAttribute:KRSlockChromeAttributeName value:attrs.slockChrome range:range]; - } - - // Slock chip chrome (task #439): a chip token (inlineCode/channel/thread/task/ - // selfMention/active) draws a bordered fill and must NOT also carry the text - // underline that the shared span style leaves on tag kinds (React MSG_REF_CHIP has - // no underline). The underline belongs only to ordinaryMention (@other/@agent). - BOOL slockChipSuppressesUnderline = - attrs.slockChrome.length > 0 && ![attrs.slockChrome isEqualToString:@"ordinaryMention"]; - if (attrs.textDecoration == KRTextDecorationLineTypeUnderline && !slockChipSuppressesUnderline) { + if (attrs.textDecoration == KRTextDecorationLineTypeUnderline) { NSUnderlineStyle underlineStyle = attrs.textDecorationThickness ? NSUnderlineStyleThick : NSUnderlineStyleSingle; [attributedString addAttribute:NSUnderlineStyleAttributeName value:@(underlineStyle) range:range]; if (attrs.textDecorationColor) { diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 8c603111f..dcb77a6fd 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -28,47 +28,14 @@ NSString *const KRInlineBoxStyleAttributeName = @"KRInlineBoxStyleAttributeName"; NSString *const KRInlineBoxSemanticAttributeName = @"KRInlineBoxSemanticAttributeName"; -#pragma mark - Slock rich-text chip chrome (task #439) - -// TEMPORARY BRIDGE TO TASK #442. These constants mirror the Android drawer -// (core-render-android KRRichTextViewDrawer.kt) and the shared token source -// SlockRichTextChromeStyleTokens.* / SLOCK_RICHTEXT_INLINE_CODE_* (mobile PR #435, -// commit 5ffc5a044). #442 will serialize the resolved token fields into the span -// prop so both drawers read prop data and these baked constants are deleted -// (acceptance: fork grep finds no SLOCK constants). Do NOT let these become a new -// long-term source of truth. -// Fill colors: SlockRichTextChromeStyleTokens.InlineCode.chipFill etc. (ARGB). static const uint32_t kKRSlockInlineCodeFillARGB = 0x66FFD440; // react bg-soft-signal/40 = #FFD440 @ 40% (was 0x66FFD84D, the Android outlier — SlockMarkdown.kt:1485-90) -static const uint32_t kKRSlockChannelFillARGB = 0x4DFE7DA8; // Channel.chipFill (pink @ 30%) -static const uint32_t kKRSlockThreadFillARGB = 0x4D27CCF3; // Thread.chipFill (cyan @ 30%) -static const uint32_t kKRSlockTaskFillARGB = 0x66FFD440; // Task.chipFill (yellow @ 40%) -static const uint32_t kKRSlockSelfMentionFillARGB = 0xFFFFD440; // SelfMention.chipFill (opaque yellow) -// Geometry ratios × textSize: SLOCK_RICHTEXT_INLINE_CODE_EDGE_PADDING / _CHAR_WRAP_BREAK et al. -static const CGFloat kKRSlockHorizontalPaddingRatio = 4.0 / 15.0; // React MSG_REF_CHIP px-1 (≈4px @ 15pt) -static const CGFloat kKRSlockLineHeightRatio = 1.5; // React MSG_REF_CHIP leading-[1.5] static const CGFloat kKRSlockBorderWidthPt = 1.0; // 1dp black border (border-black) -static UIColor *KRSlockChromeFillColor(NSString *chrome) { - uint32_t argb; - if ([chrome isEqualToString:@"inlineCode"]) { - argb = kKRSlockInlineCodeFillARGB; - } else if ([chrome isEqualToString:@"channel"]) { - argb = kKRSlockChannelFillARGB; - } else if ([chrome isEqualToString:@"thread"]) { - argb = kKRSlockThreadFillARGB; - } else if ([chrome isEqualToString:@"task"]) { - argb = kKRSlockTaskFillARGB; - } else if ([chrome isEqualToString:@"selfMention"] || [chrome isEqualToString:@"active"]) { - argb = kKRSlockSelfMentionFillARGB; - } else { - // ordinaryMention (and any @other/@agent) renders as an underline via the - // existing text SpanStyle, NOT a chip — no fill/border here. - return nil; - } - CGFloat a = ((argb >> 24) & 0xFF) / 255.0; - CGFloat r = ((argb >> 16) & 0xFF) / 255.0; - CGFloat g = ((argb >> 8) & 0xFF) / 255.0; - CGFloat b = (argb & 0xFF) / 255.0; +static UIColor *KRSlockInlineCodeFillColor(void) { + CGFloat a = ((kKRSlockInlineCodeFillARGB >> 24) & 0xFF) / 255.0; + CGFloat r = ((kKRSlockInlineCodeFillARGB >> 16) & 0xFF) / 255.0; + CGFloat g = ((kKRSlockInlineCodeFillARGB >> 8) & 0xFF) / 255.0; + CGFloat b = (kKRSlockInlineCodeFillARGB & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; } @@ -628,10 +595,7 @@ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origi _drawAtPoint = origin; [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; [self kr_drawInlineBoxChromeForGlyphRange:glyphsToShow atPoint:origin]; - // Slock chip chrome (task #439). Drawn in drawBackground (before glyphs) so the - // fill sits behind the text; the border is inset from the glyphs by the leading/ - // trailing NBSP padding reserved on the shared side, so it never overlaps glyphs. - [self kr_drawSlockChipChromeForGlyphRange:glyphsToShow atPoint:origin]; + [self kr_drawSlockInlineCodeChromeForGlyphRange:glyphsToShow atPoint:origin]; _drawAtPoint = CGPointZero; } @@ -703,11 +667,7 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi }]; } -// TEMPORARY BRIDGE TO TASK #442 — ports core-render-android KRRichTextViewDrawer.kt -// drawSlockInlineCodeChrome/drawSlockMarkdownTagChrome geometry to TextKit. #442 moves -// the resolved token values into span props so this reads prop data instead of the -// baked kKRSlock* constants above. -- (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { +- (void)kr_drawSlockInlineCodeChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { NSTextStorage *textStorage = self.textStorage; if (textStorage.length == 0) { return; @@ -728,29 +688,17 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi inRange:charRange options:0 usingBlock:^(id value, NSRange runRange, BOOL *stop) { - if (![value isKindOfClass:[NSString class]] || [(NSString *)value length] == 0) { + if (![value isKindOfClass:[NSString class]] || ![(NSString *)value isEqualToString:@"inlineCode"]) { return; } - UIColor *fillColor = KRSlockChromeFillColor((NSString *)value); - if (!fillColor) { - return; // underline-only kinds draw no chip - } + UIColor *fillColor = KRSlockInlineCodeFillColor(); NSRange runGlyphRange = [self glyphRangeForCharacterRange:runRange actualCharacterRange:NULL]; if (runGlyphRange.length == 0) { return; } NSUInteger runGlyphEnd = NSMaxRange(runGlyphRange); - // Per line fragment the run spans: vertical from FONT METRICS (baseline ± - // ascender/descender + vPadding, tight to the glyph box like Android/React) — - // NOT the line-fragment rect (which includes line leading → chip too tall/high, - // task #439 bug ①). Horizontal from boundingRectForGlyphRange (tight to the - // glyphs on THIS line → no wrapped-segment right overhang). [self enumerateLineFragmentsForGlyphRange:runGlyphRange usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *lineContainer, NSRange lineGlyphRange, BOOL *lineStop) { - // enumerateLineFragmentsForGlyphRange gives the WHOLE line fragment's glyph - // range, not the run's glyphs on that line — intersect with the run so the - // chip fill bounds only THIS token's glyphs (task #439 bug: without this the - // fill spanned the entire line instead of a discrete per-token chip). NSRange segmentGlyphRange = NSIntersectionRange(lineGlyphRange, runGlyphRange); if (segmentGlyphRange.length == 0) { return; @@ -761,16 +709,9 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi ? [textStorage attribute:NSFontAttributeName atIndex:lineCharRange.location effectiveRange:NULL] : nil; CGFloat textSize = font ? font.pointSize : 15.0; - CGFloat ascender = font ? font.ascender : textSize * 0.75; // > 0, above baseline - CGFloat descender = font ? font.descender : -textSize * 0.25; // < 0, below baseline - CGFloat hPadding = textSize * kKRSlockHorizontalPaddingRatio; // React px-1 ≈ 4px each side - CGFloat chipHeight = textSize * kKRSlockLineHeightRatio; // React leading-[1.5] - CGPoint loc = [self locationForGlyphAtIndex:segmentGlyphRange.location]; - CGFloat baseline = lineRect.origin.y + loc.y + origin.y; BOOL isRunStart = (segmentGlyphRange.location == runGlyphRange.location); BOOL isRunEnd = (NSMaxRange(segmentGlyphRange) >= runGlyphEnd); - BOOL isInlineCode = [(NSString *)value isEqualToString:@"inlineCode"]; - if (isInlineCode && lineCharRange.length > 0) { + if (lineCharRange.length > 0) { id firstAtom = [textStorage attribute:NSAttachmentAttributeName atIndex:lineCharRange.location effectiveRange:NULL]; @@ -782,50 +723,19 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi isRunEnd = [lastAtom respondsToSelector:@selector(kr_slockInlineCodeTrailingEdge)] && [(id)lastAtom kr_slockInlineCodeTrailingEdge]; } - CGFloat left; - CGFloat right; - if (isInlineCode) { - // Atom bounds already include 4/15 inner padding + 2/15 outer - // margin at the global span edges. Paint the final line-fragment - // chain only after TextKit wrapping, trimming the transparent - // outer margin while keeping the inner padding inside chrome. - CGFloat outerMargin = textSize * (2.0 / 15.0); - left = CGRectGetMinX(gb) + origin.x + (isRunStart ? outerMargin : 0.0); - right = CGRectGetMaxX(gb) + origin.x - (isRunEnd ? outerMargin : 0.0); - } else { - // Legacy non-atomic chrome fallback. - CGFloat glyphLeft = lineRect.origin.x + loc.x + origin.x; - CGFloat glyphRight = CGRectGetMaxX(gb) + origin.x; - CGFloat boxReserve = hPadding; - left = isRunStart ? (glyphLeft - boxReserve) : glyphLeft; - right = isRunEnd ? (glyphRight + boxReserve) : glyphRight; - } + CGFloat outerMargin = textSize * (2.0 / 15.0); + CGFloat left = CGRectGetMinX(gb) + origin.x + (isRunStart ? outerMargin : 0.0); + CGFloat right = CGRectGetMaxX(gb) + origin.x - (isRunEnd ? outerMargin : 0.0); if (right <= left) { return; } - CGFloat top; - CGFloat bottom; - if (isInlineCode) { - // The atom attachment already owns the exact 1.5x box height and - // centers its glyph image inside that box. Reuse TextKit's final - // attachment bounds for chrome so measurement, glyph baseline, - // fill and border all share one vertical coordinate system. - top = CGRectGetMinY(gb) + origin.y; - bottom = CGRectGetMaxY(gb) + origin.y; - } else { - // Legacy glyph-flow chrome: center a 1.5x box on font metrics. - CGFloat centerY = baseline - (ascender + descender) / 2.0; - top = centerY - chipHeight / 2.0; - bottom = centerY + chipHeight / 2.0; - } + CGFloat top = CGRectGetMinY(gb) + origin.y; + CGFloat bottom = CGRectGetMaxY(gb) + origin.y; if (bottom <= top) { return; } - // CoreGraphics fills (portable across iOS + [macOS]; UIRectFill is iOS-only). CGContextSetFillColorWithColor(ctx, fillColor.CGColor); CGContextFillRect(ctx, CGRectMake(left, top, right - left, bottom - top)); - // Black 1dp border, square corners, four crisp edge rects - // (SlockRichTextChromeStyleTokens border; KRRichTextViewDrawer.drawSlock*Border). CGFloat bw = kKRSlockBorderWidthPt; CGFloat bl = floor(left); CGFloat bt = floor(top); @@ -834,14 +744,10 @@ - (void)kr_drawSlockChipChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGContextSetFillColorWithColor(ctx, [UIColor blackColor].CGColor); CGContextFillRect(ctx, CGRectMake(bl, bt, br - bl, bw)); CGContextFillRect(ctx, CGRectMake(bl, bb - bw, br - bl, bw)); - // A wrapping inline-code chain has only two semantic side edges: - // the global span start and end. Line-wrap boundaries are internal - // atom joins; drawing vertical borders there was the old experiment's - // clipping bug (continuation first glyph sat under a pre-drawn edge). - if (!isInlineCode || isRunStart) { + if (isRunStart) { CGContextFillRect(ctx, CGRectMake(bl, bt, bw, bb - bt)); } - if (!isInlineCode || isRunEnd) { + if (isRunEnd) { CGContextFillRect(ctx, CGRectMake(br - bw, bt, bw, bb - bt)); } }]; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp index 30902f110..ec2034b59 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRRichTextShadow.cpp @@ -73,11 +73,11 @@ constexpr char16_t kSlockNonBreakingSpace = u'\u00A0'; constexpr char16_t kSlockZeroWidthBreak = u'\u200B'; constexpr char16_t kInlineBoxWordJoiner = u'\u2060'; constexpr char16_t kObjectReplacementCharacter = u'\uFFFC'; -constexpr float kSlockInnerPaddingRatio = 4.0f / 15.0f; -constexpr float kSlockOuterMarginRatio = 2.0f / 15.0f; -constexpr float kSlockChipBorderWidthVp = 1.0f; -constexpr float kSlockTrailingMarginRatio = 1.0f / 15.0f; -constexpr float kSlockChipLineHeightRatio = 1.5f; +constexpr float kSlockInlineCodeInnerPaddingRatio = 4.0f / 15.0f; +constexpr float kSlockInlineCodeOuterMarginRatio = 2.0f / 15.0f; +constexpr float kSlockInlineCodeBorderWidthVp = 1.0f; +constexpr float kSlockInlineCodeTrailingMarginRatio = 1.0f / 15.0f; +constexpr float kSlockInlineCodeLineHeightRatio = 1.5f; constexpr char kInlineBoxGroupIndexKey[] = "__kr_inline_box_group_index__"; constexpr char kTopLevelSpanIndexKey[] = "__kr_top_level_span_index__"; @@ -148,23 +148,8 @@ KRSlockInlineCodeTextPlan KRBuildSlockInlineCodeTextPlan(const std::string &text return result; } -uint32_t KRSlockChromeFillColor(const std::string &kind) { - if (kind == "inlineCode") { - return 0x66FFD440; - } - if (kind == "channel") { - return 0x4DFE7DA8; - } - if (kind == "thread") { - return 0x4D27CCF3; - } - if (kind == "task") { - return 0x66FFD440; - } - if (kind == "selfMention" || kind == "active") { - return 0xFFFFD440; - } - return 0; +uint32_t KRSlockInlineCodeFillColor() { + return 0x66FFD440; } } // namespace @@ -764,11 +749,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w const bool slockInlineCode = GetKRValue("slockInlineCode", spanMap, spanMap)->toBool(); const bool slockInlineCodeTrailingMargin = GetKRValue("slockInlineCodeTrailingMargin", spanMap, spanMap)->toBool(); - const std::string slockTagChrome = - GetKRValue("slockMarkdownTagChrome", spanMap, spanMap)->toString(); - const std::string slockChromeKind = slockInlineCode ? "inlineCode" : slockTagChrome; - const uint32_t slockFillColor = KRSlockChromeFillColor(slockChromeKind); - const bool isSlockChip = slockFillColor != 0; + const uint32_t slockInlineCodeFillColor = KRSlockInlineCodeFillColor(); const std::string inlineBoxBackgroundColorStr = GetKRValue("inlineBoxBackgroundColor", spanMap, spanMap)->toString(); const std::string inlineBoxBorderColorStr = @@ -794,7 +775,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w inlineBoxPaddingStart > 0 || inlineBoxPaddingEnd > 0 || inlineBoxPaddingTop > 0 || inlineBoxPaddingBottom > 0 || inlineBoxMarginStart > 0 || inlineBoxMarginEnd > 0 || inlineBoxCornerRadius > 0); - const bool hasBoxChrome = isSlockChip || isInlineBox; + const bool hasBoxChrome = slockInlineCode || isInlineBox; if (hasBoxChrome) { textDecoration = TEXT_DECORATION_NONE; } @@ -1016,8 +997,8 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w // space remains semantic text, while layout uses a 1/15 transparent // advance instead of painting a visible whitespace glyph. OH_Drawing_PlaceholderSpan trailingMargin = { - fontSize * kSlockTrailingMarginRatio, - fontSize * kSlockChipLineHeightRatio, + fontSize * kSlockInlineCodeTrailingMarginRatio, + fontSize * kSlockInlineCodeLineHeightRatio, ALIGNMENT_CENTER_OF_ROW_BOX, TEXT_BASELINE_ALPHABETIC, 0, @@ -1031,22 +1012,22 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w } else if (hasBoxChrome) { const float borderWidth = isInlineBox ? inlineBoxBorderWidth - : std::max(1.0f, static_cast(dpi) * kSlockChipBorderWidthVp); + : std::max(1.0f, static_cast(dpi) * kSlockInlineCodeBorderWidthVp); const float paddingStart = isInlineBox ? inlineBoxPaddingStart - : fontSize * kSlockInnerPaddingRatio; + : fontSize * kSlockInlineCodeInnerPaddingRatio; const float paddingEnd = isInlineBox ? inlineBoxPaddingEnd - : fontSize * kSlockInnerPaddingRatio; + : fontSize * kSlockInlineCodeInnerPaddingRatio; const float marginStart = isInlineBox ? inlineBoxMarginStart - : fontSize * kSlockOuterMarginRatio; + : fontSize * kSlockInlineCodeOuterMarginRatio; const float marginEnd = isInlineBox ? inlineBoxMarginEnd - : fontSize * kSlockOuterMarginRatio; + : fontSize * kSlockInlineCodeOuterMarginRatio; const float boxHeight = isInlineBox ? fontSize + inlineBoxPaddingTop + inlineBoxPaddingBottom + borderWidth * 2.0f - : fontSize * kSlockChipLineHeightRatio; + : fontSize * kSlockInlineCodeLineHeightRatio; OH_Drawing_PlaceholderSpan leadingEdgePlaceholder = { marginStart + borderWidth + paddingStart, boxHeight, @@ -1095,7 +1076,7 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w ? (inlineBoxBackgroundColorStr.length() ? kuikly::util::ConvertToHexColor(inlineBoxBackgroundColorStr) : 0) - : slockFillColor, + : slockInlineCodeFillColor, isInlineBox ? (inlineBoxBorderColorStr.length() ? kuikly::util::ConvertToHexColor(inlineBoxBorderColorStr) diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index 0258a2b9f..9395385e9 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt @@ -566,13 +566,6 @@ open class TextSpan : TextAttr(), ISpan { return this } - fun slockMarkdownTagChrome(kind: String): TextSpan { - if (kind.isNotBlank()) { - setProp(TextConst.SLOCK_MARKDOWN_TAG_CHROME, kind) - } - return this - } - /** Attach generic inline box decoration to this existing text span. */ fun inlineBoxStyle(style: InlineBoxSpanStyle): TextSpan { style.backgroundColor?.let { setProp(TextConst.INLINE_BOX_BACKGROUND_COLOR, it.toString()) } diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt index bbad4db86..2672d4063 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/TextView.kt @@ -573,7 +573,6 @@ object TextConst { const val TEXT_USE_DP_FONT_SIZE_DIM = "useDpFontSizeDim" const val SLOCK_INLINE_CODE = "slockInlineCode" const val SLOCK_INLINE_CODE_TRAILING_MARGIN = "slockInlineCodeTrailingMargin" - const val SLOCK_MARKDOWN_TAG_CHROME = "slockMarkdownTagChrome" const val INLINE_BOX_BACKGROUND_COLOR = "inlineBoxBackgroundColor" const val INLINE_BOX_BORDER_COLOR = "inlineBoxBorderColor" const val INLINE_BOX_BORDER_WIDTH = "inlineBoxBorderWidth" From d4da2a48b2c9c17dd8694fd4e3e15f71719fb1e0 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 09:57:11 +0800 Subject: [PATCH 107/187] fix(input): preserve rapid controlled edit order (#37) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../compose/foundation/text/CoreTextField.kt | 257 ++++++++++--- .../text/TextInputCallbackArbiterTest.kt | 347 ++++++++++++++++++ .../components/input/KRTextFieldView.cpp | 74 +++- .../expand/components/input/KRTextFieldView.h | 36 +- 4 files changed, 647 insertions(+), 67 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputCallbackArbiterTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt index cd8fa6b3f..c57944ce7 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/CoreTextField.kt @@ -208,13 +208,15 @@ internal fun CoreTextField( var currentLimitReached by remember { mutableStateOf(false) } // 一次性标记:收到超限事件后,等待紧随其后的真实长度回调再统一通知业务,避免先吐旧长度 var pendingLimitChangeNotification by remember { mutableStateOf(false) } - // 一次性标记:仅在当前轮原生 textInputStateChange 已经覆盖同一文本变更时,跳过紧随其后的 textDidChange fallback - var pendingTextInputStateText by remember { mutableStateOf(null) } // 记录上一次原生层真实生效的编辑态,避免仅因 text 相同而误判 selection/composition 同步 var lastSyncedTextInputState by remember { mutableStateOf(null) } + // Updater.set uses structural equality. Advance this generation for every emitted native + // callback and authoritative sync so equal historical values still reach reconciliation, + // while apply blocks composed against an older editor state can be rejected. + var inputStateGeneration by remember { mutableStateOf(TextInputStateGeneration()) } val textInputSyncRevisionTracker = remember { TextInputSyncRevisionTracker() } - // 标记是否正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 - var isProcessingNativeEvent by remember { mutableStateOf(false) } + val textInputCallbackArbiter = remember { TextInputCallbackArbiter() } + val controlledStateArbiter = remember { TextInputControlledStateArbiter() } val measurePolicy = remember(value) { object : MeasurePolicy { private val placementBlock: Placeable.PlacementScope.() -> Unit = {} @@ -488,9 +490,7 @@ internal fun CoreTextField( if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { return@textInputStateChange } - // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 - isProcessingNativeEvent = true - pendingTextInputStateText = it.text + val textFieldValue = textInputCallbackArbiter.onCompleteState(it) lastSyncedTextInputState = TextInputState( text = it.text, selectionStart = it.selectionStart, @@ -500,31 +500,20 @@ internal fun CoreTextField( length = it.length, syncRevision = it.syncRevision ) + inputStateGeneration = TextInputStateGeneration() autoHeightTextAreaView.getViewAttr() .updatePropCache(TextConst.VALUE, it.text) - val composition = if ( - it.compositionStart != TextInputState.NO_COMPOSITION && - it.compositionEnd != TextInputState.NO_COMPOSITION - ) { - TextRange(it.compositionStart, it.compositionEnd) - } else { - null - } - onValueChange( - TextFieldValue( - it.text, - selection = TextRange(it.selectionStart, it.selectionEnd), - composition = composition - ) + controlledStateArbiter.recordNativeValue( + textFieldValue, + inputStateGeneration, ) + onValueChange(textFieldValue) dispatchLimitChange(it.length, pendingLimitChangeNotification) } getViewEvent().selectionChange { if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { return@selectionChange } - // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 - isProcessingNativeEvent = true lastSyncedTextInputState = TextInputState( text = it.text, selectionStart = it.selectionStart, @@ -534,6 +523,7 @@ internal fun CoreTextField( length = it.length, syncRevision = it.syncRevision ) + inputStateGeneration = TextInputStateGeneration() val composition = if ( it.compositionStart != TextInputState.NO_COMPOSITION && it.compositionEnd != TextInputState.NO_COMPOSITION @@ -542,35 +532,48 @@ internal fun CoreTextField( } else { null } - onValueChange( - TextFieldValue( - it.text, - selection = TextRange(it.selectionStart, it.selectionEnd), - composition = composition - ) + val textFieldValue = TextFieldValue( + it.text, + selection = TextRange(it.selectionStart, it.selectionEnd), + composition = composition, + ) + controlledStateArbiter.recordNativeValue( + textFieldValue, + inputStateGeneration, ) + onValueChange(textFieldValue) } getViewEvent().textDidChange { if (textInputSyncRevisionTracker.isStale(it.syncRevision)) { return@textDidChange } - val shouldIgnoreFallback = pendingTextInputStateText == it.text - pendingTextInputStateText = null - if (shouldIgnoreFallback) { + val fallbackValue = textInputCallbackArbiter.onLegacyTextChange( + text = it.text, + lastSyncedState = lastSyncedTextInputState, + ) + if (fallbackValue == null) { return@textDidChange } autoHeightTextAreaView.getViewAttr() .updatePropCache(TextConst.VALUE, it.text) - // textDidChange 不含 selection 信息,若 lastSyncedTextInputState 文本一致则沿用其选区, - // 避免用 TextRange.Zero(0,0) 覆盖原生层正确光标。 - val preservedSelection = lastSyncedTextInputState?.let { state -> - if (state.text == it.text) { - TextRange(state.selectionStart, state.selectionEnd) - } else { - TextRange.Zero - } - } ?: TextRange.Zero - onValueChange(TextFieldValue(text = it.text, selection = preservedSelection)) + val fallbackComposition = fallbackValue.composition + lastSyncedTextInputState = TextInputState( + text = fallbackValue.text, + selectionStart = fallbackValue.selection.start, + selectionEnd = fallbackValue.selection.end, + compositionStart = fallbackComposition?.start + ?: TextInputState.NO_COMPOSITION, + compositionEnd = fallbackComposition?.end + ?: TextInputState.NO_COMPOSITION, + length = it.length, + syncRevision = it.syncRevision, + ) + inputStateGeneration = TextInputStateGeneration() + controlledStateArbiter.recordNativeValue( + fallbackValue, + inputStateGeneration, + ) + onValueChange(fallbackValue) dispatchLimitChange(it.length, pendingLimitChangeNotification) } } @@ -617,27 +620,36 @@ internal fun CoreTextField( } } - set(value) { - if (it == null) return@set + set(TextInputControlledUpdate(value, inputStateGeneration)) { update -> withTextAreaView { - val composition = value.composition + val controlledValue = update.value + val composition = controlledValue.composition val incomingTextInputState = TextInputState( - text = value.text, - selectionStart = value.selection.start, - selectionEnd = value.selection.end, + text = controlledValue.text, + selectionStart = controlledValue.selection.start, + selectionEnd = controlledValue.selection.end, compositionStart = composition?.start ?: TextInputState.NO_COMPOSITION, compositionEnd = composition?.end ?: TextInputState.NO_COMPOSITION ) getViewAttr().updatePropCache(TextConst.VALUE, incomingTextInputState.text) - // 处理原生事件回流时,只有完整编辑态真的不同才反向同步,避免用旧 selection/composition 覆盖原生态 - val shouldSyncToNative = !isProcessingNativeEvent || + // Native input can advance before older Compose changes are applied. The + // generation carried by this update distinguishes those stale changes from a + // current business decision that retains or recreates a historical value. + val shouldSuppressControlledUpdate = + controlledStateArbiter.shouldSuppressControlledUpdate( + value = controlledValue, + updateGeneration = update.inputStateGeneration, + latestGeneration = inputStateGeneration, + ) + val shouldSyncToNative = !shouldSuppressControlledUpdate && !(lastSyncedTextInputState?.hasSameEditingState(incomingTextInputState) ?: false) if (shouldSyncToNative) { val revisionedState = incomingTextInputState.copy( syncRevision = textInputSyncRevisionTracker.issue() ) + inputStateGeneration = TextInputStateGeneration() setTextInputState(revisionedState) lastSyncedTextInputState = revisionedState } @@ -645,8 +657,6 @@ internal fun CoreTextField( // 长度计算统一依赖原生层回调,避免 Kotlin 层和原生层计算不一致 // 原生层会在 textInputStateChange 回调中返回正确的 length - // 重置标志,等待下一次原生事件 - isProcessingNativeEvent = false } } }, @@ -658,6 +668,149 @@ internal fun CoreTextField( internal fun FocusRequester.focusIfAttached(): Boolean = hasAttachedNodes() && focus() +internal data class TextInputControlledUpdate( + val value: TextFieldValue, + val inputStateGeneration: TextInputStateGeneration, +) + +// Identity is the generation token; numeric ordering would add an unnecessary wraparound case. +internal class TextInputStateGeneration + +internal class TextInputCallbackArbiter { + private val completeTextsAwaitingLegacy = mutableListOf() + private val legacyTextsAwaitingComplete = mutableListOf() + + fun onCompleteState(state: TextInputState): TextFieldValue { + val matchingLegacyIndex = legacyTextsAwaitingComplete.indexOf(state.text) + if (matchingLegacyIndex >= 0) { + legacyTextsAwaitingComplete.removeAt(matchingLegacyIndex) + } else { + recordPendingText(completeTextsAwaitingLegacy, state.text) + } + val composition = if ( + state.compositionStart != TextInputState.NO_COMPOSITION && + state.compositionEnd != TextInputState.NO_COMPOSITION + ) { + TextRange(state.compositionStart, state.compositionEnd) + } else { + null + } + return TextFieldValue( + text = state.text, + selection = TextRange(state.selectionStart, state.selectionEnd), + composition = composition, + ) + } + + fun onLegacyTextChange( + text: String, + lastSyncedState: TextInputState?, + ): TextFieldValue? { + // Complete callbacks own text, selection and composition. Pair by text across scheduling + // turns so a delayed legacy callback cannot overwrite a newer complete native state. + val matchingCompleteIndex = completeTextsAwaitingLegacy.indexOf(text) + if (matchingCompleteIndex >= 0) { + completeTextsAwaitingLegacy.removeAt(matchingCompleteIndex) + return null + } + + // Some platforms emit legacy text before the complete state, and marked-text input may + // intentionally be legacy-only. Keep the unmatched callback available for one-to-one + // pairing without invalidating unrelated complete callbacks that may still arrive later. + recordPendingText(legacyTextsAwaitingComplete, text) + + val preservedState = lastSyncedState?.takeIf { state -> state.text == text } + val preservedSelection = preservedState?.let { state -> + TextRange(state.selectionStart, state.selectionEnd) + } ?: TextRange.Zero + val preservedComposition = preservedState?.let { state -> + if ( + state.compositionStart != TextInputState.NO_COMPOSITION && + state.compositionEnd != TextInputState.NO_COMPOSITION + ) { + TextRange(state.compositionStart, state.compositionEnd) + } else { + null + } + } + return TextFieldValue( + text = text, + selection = preservedSelection, + composition = preservedComposition, + ) + } + + private fun recordPendingText(queue: MutableList, text: String) { + queue += text + if (queue.size > MAX_PENDING_CALLBACKS) { + queue.removeAt(0) + } + } + + private companion object { + const val MAX_PENDING_CALLBACKS = 64 + } +} + +internal class TextInputControlledStateArbiter { + private val pendingNativeValues = mutableListOf() + + fun recordNativeValue( + value: TextFieldValue, + inputStateGeneration: TextInputStateGeneration, + ) { + if (pendingNativeValues.lastOrNull()?.value === value) { + return + } + pendingNativeValues += PendingNativeValue(value, inputStateGeneration) + if (pendingNativeValues.size > MAX_PENDING_NATIVE_VALUES) { + pendingNativeValues.removeAt(0) + } + } + + fun shouldSuppressControlledUpdate( + value: TextFieldValue, + updateGeneration: TextInputStateGeneration, + latestGeneration: TextInputStateGeneration, + ): Boolean { + // Equality is insufficient here: a formatter or external owner may intentionally + // produce a new value that matches an older native state. Only the exact object passed + // to onValueChange can carry a direct state-hoisting token. + val matchingIndex = pendingNativeValues.indexOfFirst { + it.value === value + } + + // An apply block composed before a newer native callback is stale regardless of whether + // it contains a direct token or a transformed value. A current-generation composition + // will follow and decide the authoritative state. + if (updateGeneration !== latestGeneration) { + if (matchingIndex >= 0) { + pendingNativeValues.removeAt(matchingIndex) + } + return true + } + + if (matchingIndex < 0) { + return false + } + + val token = pendingNativeValues.removeAt(matchingIndex) + // Default structural snapshot state can retain an older callback object when a formatter + // returns an equal historical value. It is a direct echo only in the generation that + // created it; in a newer current generation the retained object is authoritative. + return token.inputStateGeneration === updateGeneration + } + + private data class PendingNativeValue( + val value: TextFieldValue, + val inputStateGeneration: TextInputStateGeneration, + ) + + private companion object { + const val MAX_PENDING_NATIVE_VALUES = 64 + } +} + internal class TextInputSyncRevisionTracker { private var latestIssuedRevision: Int = 0 diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputCallbackArbiterTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputCallbackArbiterTest.kt new file mode 100644 index 000000000..4f83b01ae --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputCallbackArbiterTest.kt @@ -0,0 +1,347 @@ +package com.tencent.kuikly.compose.foundation.text + +import com.tencent.kuikly.compose.ui.text.TextRange +import com.tencent.kuikly.compose.ui.text.input.TextFieldValue +import com.tencent.kuikly.core.views.TextInputState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TextInputCallbackArbiterTest { + @Test + fun delayedLegacyTextCannotOverwriteNewerCompleteState() { + val arbiter = TextInputCallbackArbiter() + + arbiter.onCompleteState(state(text = "1234567890123456789012", selection = 22)) + val complete = arbiter.onCompleteState(state(text = "1234567890123456789012345", selection = 25)) + val delayedLegacy = arbiter.onLegacyTextChange( + text = "1234567890123456789012", + lastSyncedState = state(text = complete.text, selection = complete.selection.end), + ) + + assertEquals(25, complete.text.length) + assertEquals(25, complete.selection.end) + assertNull(delayedLegacy) + } + + @Test + fun matchingLegacyTextAfterCompleteStateDoesNotEmitTwice() { + val arbiter = TextInputCallbackArbiter() + val complete = arbiter.onCompleteState(state(text = "current", selection = 7)) + + assertEquals("current", complete.text) + assertNull(arbiter.onLegacyTextChange("current", state("current", 7))) + } + + @Test + fun legacyOnlyPlatformStillUpdatesText() { + val arbiter = TextInputCallbackArbiter() + + val legacy = arbiter.onLegacyTextChange( + text = "legacy edit", + lastSyncedState = null, + ) + + assertEquals("legacy edit", legacy?.text) + assertEquals(0, legacy?.selection?.start) + assertEquals(0, legacy?.selection?.end) + } + + @Test + fun sameTextUnmatchedLegacyPreservesSelectionAndComposition() { + val arbiter = TextInputCallbackArbiter() + val lastSyncedState = TextInputState( + text = "marked text", + selectionStart = 2, + selectionEnd = 7, + compositionStart = 1, + compositionEnd = 8, + ) + + val legacy = arbiter.onLegacyTextChange( + text = lastSyncedState.text, + lastSyncedState = lastSyncedState, + ) + + assertEquals(TextRange(2, 7), legacy?.selection) + assertEquals(TextRange(1, 8), legacy?.composition) + } + + @Test + fun unmatchedLegacyMarkedTextRemainsSupportedAfterCompleteState() { + val arbiter = TextInputCallbackArbiter() + arbiter.onCompleteState(state(text = "committed", selection = 9)) + + val markedText = arbiter.onLegacyTextChange( + text = "committedp", + lastSyncedState = state("committed", 9), + ) + + assertEquals("committedp", markedText?.text) + } + + @Test + fun unmatchedLegacyDoesNotInvalidateOtherPendingCompleteCallbacks() { + val arbiter = TextInputCallbackArbiter() + arbiter.onCompleteState(state(text = "complete-a", selection = 10)) + arbiter.onCompleteState(state(text = "complete-b", selection = 10)) + + val legacyBeforeComplete = arbiter.onLegacyTextChange( + text = "marked-c", + lastSyncedState = state(text = "complete-b", selection = 10), + ) + + assertEquals("marked-c", legacyBeforeComplete?.text) + assertNull(arbiter.onLegacyTextChange("complete-a", state("marked-c", 8))) + assertNull(arbiter.onLegacyTextChange("complete-b", state("marked-c", 8))) + + val completeAfterLegacy = arbiter.onCompleteState( + TextInputState( + text = "marked-c", + selectionStart = 2, + selectionEnd = 7, + compositionStart = 1, + compositionEnd = 8, + ), + ) + + assertEquals(TextRange(2, 7), completeAfterLegacy.selection) + assertEquals(TextRange(1, 8), completeAfterLegacy.composition) + } + + @Test + fun sameTextSelectionUpdateIsNotResetByLegacyCallback() { + val arbiter = TextInputCallbackArbiter() + val selectionUpdate = arbiter.onCompleteState(state(text = "abcdef", selection = 3)) + + assertEquals(3, selectionUpdate.selection.start) + assertEquals(3, selectionUpdate.selection.end) + assertNull(arbiter.onLegacyTextChange("abcdef", state("abcdef", 3))) + } + + @Test + fun rapidDeleteKeepsCompleteStateOrderAndNativeSelection() { + val arbiter = TextInputCallbackArbiter() + val emitted = mutableListOf>() + + listOf(9, 8, 7, 6, 5, 4).forEachIndexed { index, length -> + val value = arbiter.onCompleteState( + state(text = "x".repeat(length), selection = length), + ) + emitted += value.text.length to value.selection.end + if (index > 0) { + val previousLength = length + 1 + assertNull( + arbiter.onLegacyTextChange( + text = "x".repeat(previousLength), + lastSyncedState = state(value.text, value.selection.end), + ), + ) + } + } + + assertEquals(listOf(9 to 9, 8 to 8, 7 to 7, 6 to 6, 5 to 5, 4 to 4), emitted) + } + + @Test + fun queuedNativeEchoCannotRollbackNewerNativeEdit() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "1234567", selection = 7) + val secondNativeValue = value(text = "123456789", selection = 9) + val firstGeneration = generation() + val secondGeneration = generation() + arbiter.recordNativeValue(firstNativeValue, firstGeneration) + arbiter.recordNativeValue(secondNativeValue, secondGeneration) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + updateGeneration = firstGeneration, + latestGeneration = secondGeneration, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + } + + @Test + fun coalescedLatestEchoKeepsEarlierDirectEchoToken() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "1234567", selection = 7) + val secondNativeValue = value(text = "123456789", selection = 9) + val firstGeneration = generation() + val secondGeneration = generation() + arbiter.recordNativeValue(firstNativeValue, firstGeneration) + arbiter.recordNativeValue(secondNativeValue, secondGeneration) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + updateGeneration = firstGeneration, + latestGeneration = secondGeneration, + ), + ) + } + + @Test + fun legacyZeroSelectionEchoIsNotWrittenBackToNative() { + val arbiter = TextInputControlledStateArbiter() + val nativeValue = value(text = "1234567", selection = 0) + val currentGeneration = generation() + arbiter.recordNativeValue(nativeValue, currentGeneration) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = nativeValue, + updateGeneration = currentGeneration, + latestGeneration = currentGeneration, + ), + ) + } + + @Test + fun transformedControlledValueRemainsAuthoritative() { + val arbiter = TextInputControlledStateArbiter() + val currentGeneration = generation() + arbiter.recordNativeValue( + value(text = "draft", selection = 5), + currentGeneration, + ) + + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = value(text = "DRAFT", selection = 5), + updateGeneration = currentGeneration, + latestGeneration = currentGeneration, + ), + ) + } + + @Test + fun equivalentHistoricalValueFromBusinessRemainsAuthoritative() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "draft", selection = 1) + val secondNativeValue = value(text = "draft", selection = 2) + val firstGeneration = generation() + val secondGeneration = generation() + arbiter.recordNativeValue(firstNativeValue, firstGeneration) + arbiter.recordNativeValue(secondNativeValue, secondGeneration) + + val normalizedBusinessValue = value(text = "draft", selection = 1) + + assertEquals(firstNativeValue, normalizedBusinessValue) + assertFalse(firstNativeValue === normalizedBusinessValue) + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = normalizedBusinessValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + updateGeneration = firstGeneration, + latestGeneration = secondGeneration, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + } + + @Test + fun structurallyRetainedHistoricalCallbackIsAuthoritativeInCurrentGeneration() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "draft", selection = 1) + val secondNativeValue = value(text = "draft", selection = 2) + val firstGeneration = generation() + val secondGeneration = generation() + arbiter.recordNativeValue(firstNativeValue, firstGeneration) + arbiter.recordNativeValue(secondNativeValue, secondGeneration) + + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + updateGeneration = secondGeneration, + latestGeneration = secondGeneration, + ), + ) + } + + @Test + fun remountedSessionCannotMatchPreviousSessionToken() { + val arbiter = TextInputControlledStateArbiter() + val oldSessionGeneration = generation() + val remountedSessionGeneration = generation() + val oldSessionValue = value(text = "draft", selection = 5) + arbiter.recordNativeValue(oldSessionValue, oldSessionGeneration) + + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = oldSessionValue, + updateGeneration = remountedSessionGeneration, + latestGeneration = remountedSessionGeneration, + ), + ) + } + + @Test + fun inputStateGenerationForcesEqualHistoricalValueReconciliation() { + val historicalValue = value(text = "draft", selection = 1) + val normalizedHistoricalValue = value(text = "draft", selection = 1) + val previousGeneration = generation() + val nextGeneration = generation() + + assertEquals(historicalValue, normalizedHistoricalValue) + assertFalse(historicalValue === normalizedHistoricalValue) + assertFalse(previousGeneration === nextGeneration) + assertFalse( + TextInputControlledUpdate( + value = historicalValue, + inputStateGeneration = previousGeneration, + ) == TextInputControlledUpdate( + value = normalizedHistoricalValue, + inputStateGeneration = nextGeneration, + ), + ) + } + + private fun state(text: String, selection: Int): TextInputState = TextInputState( + text = text, + selectionStart = selection, + selectionEnd = selection, + ) + + private fun value(text: String, selection: Int): TextFieldValue = TextFieldValue( + text = text, + selection = TextRange(selection), + ) + + private fun generation(): TextInputStateGeneration = TextInputStateGeneration() +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp index d65f4bcb9..8674ac859 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.cpp @@ -66,6 +66,7 @@ constexpr char kEventTextLengthBeyondLimit[] = "textLengthBeyondLimit"; constexpr char kEventKeyboardHeightChange[] = "keyboardHeightChange"; // 键盘高度变化 constexpr char kEventTextInputStateChange[] = "textInputStateChange"; // 与 Kotlin InputView/TextAreaView TEXT_INPUT_STATE_CHANGE 一致 constexpr char kEventSelectionChange[] = "selectionChange"; // 与 Kotlin InputView.kt:426 / TextAreaView.kt:685 一致 +constexpr size_t kMaxPendingCompleteTextInputStates = 64; // textInputState JSON 协议字段名,跨端一致(参考 core/views/TextInputState.kt) constexpr char kKeyText[] = "text"; @@ -471,8 +472,13 @@ void KRTextFieldView::SetTextInputStateInternal(const std::string &json) { int selection_end = get_int(kKeySelectionEnd, selection_start); selection_end = std::max(selection_start, std::min(selection_end, u16_len)); + bool text_changed = GetContentText() != text; + + ClearPendingCompleteTextInputStates(); is_setting_text_input_state_ = true; - SetContentText(text); + if (text_changed) { + SetContentText(text); + } // ⚠️ ArkUI NODE_TEXT_INPUT_TEXT/NODE_TEXT_AREA_TEXT 的 setAttribute 会在内部异步触发 // onChange,并把光标重置到文本末尾。如果在这里同步调用 UpdateInputNodeSelectionRange, @@ -481,7 +487,7 @@ void KRTextFieldView::SetTextInputStateInternal(const std::string &json) { // 与 KRTextEditorFieldView 中 RunOnMainThreadForNextLoop 的策略一致,也与 LimitInputContentTextInMaxLength // 中已有的「先改文本后异步设光标」pattern 一致。 // 同时 is_setting_text_input_state_ flag 也延迟到此处清除,以覆盖 SetContentText 异步触发 - // OnTextDidChanged 的整个时窗,避免业务把"末尾光标"的脏 textInputStateChange 写回来形成回环。 + // OnTextDidChanged 的整个时窗,避免受控写入通过 complete 或 legacy 事件再次回流。 KRMainThread::RunOnMainThreadForNextLoop( [weakSelf = weak_from_this(), selection_start, selection_end]() { if (auto strongSelf = std::dynamic_pointer_cast(weakSelf.lock())) { @@ -501,7 +507,7 @@ void KRTextFieldView::SetTextInputStateInternal(const std::string &json) { * - 始终回 {text, selectionStart, selectionEnd, compositionStart=-1, compositionEnd=-1}; * - 仅当 length_limit_type_ != -1 时附带 length。 */ -KRRenderValueMap KRTextFieldView::CreateTextInputStateMap() { +KRTextFieldView::TextInputStateSnapshot KRTextFieldView::CreateTextInputStateSnapshot() { auto text = GetContentText(); auto range = GetInputNodeTextSelectionRange(); int u16_len = GetUTF16Length(text); @@ -510,19 +516,58 @@ KRRenderValueMap KRTextFieldView::CreateTextInputStateMap() { selection_start = std::max(0, selection_start); selection_end = std::max(selection_start, selection_end); + TextInputStateSnapshot state; + state.text = std::move(text); + state.selection_start = selection_start; + state.selection_end = selection_end; + return state; +} + +KRRenderValueMap KRTextFieldView::CreateTextInputStateMap(const TextInputStateSnapshot &state) { KRRenderValueMap map; - map[kKeyText] = NewKRRenderValue(text); - map[kKeySelectionStart] = NewKRRenderValue(selection_start); - map[kKeySelectionEnd] = NewKRRenderValue(selection_end); + map[kKeyText] = NewKRRenderValue(state.text); + map[kKeySelectionStart] = NewKRRenderValue(state.selection_start); + map[kKeySelectionEnd] = NewKRRenderValue(state.selection_end); map[kKeyCompositionStart] = NewKRRenderValue(kNoComposition); map[kKeyCompositionEnd] = NewKRRenderValue(kNoComposition); if (length_limit_type_ != -1) { - int length = CalculateTextLength(text); + int length = CalculateTextLength(state.text); map[kKeyLength] = NewKRRenderValue(length); } return map; } +KRRenderValueMap KRTextFieldView::CreateTextInputStateMap() { + return CreateTextInputStateMap(CreateTextInputStateSnapshot()); +} + +void KRTextFieldView::RecordCompleteTextInputState(const TextInputStateSnapshot &state) { + pending_complete_text_input_states_.push_back(state); + if (pending_complete_text_input_states_.size() > kMaxPendingCompleteTextInputStates) { + pending_complete_text_input_states_.pop_front(); + } +} + +bool KRTextFieldView::ConsumeCompleteTextInputState(const TextInputStateSnapshot &state) { + auto matching_state = std::find_if( + pending_complete_text_input_states_.begin(), pending_complete_text_input_states_.end(), + [&state](const TextInputStateSnapshot &pending_state) { + return state.HasSameEditingState(pending_state); + }); + if (matching_state == pending_complete_text_input_states_.end()) { + pending_complete_text_input_states_.clear(); + return false; + } + + pending_complete_text_input_states_.erase( + pending_complete_text_input_states_.begin(), matching_state + 1); + return true; +} + +void KRTextFieldView::ClearPendingCompleteTextInputStates() { + pending_complete_text_input_states_.clear(); +} + /** * getTextInputState method 路径:把当前 state 通过 callback 回吐给业务。 */ @@ -543,7 +588,9 @@ void KRTextFieldView::NotifyTextInputStateChange() { if (!text_input_state_change_callback_) { return; } - text_input_state_change_callback_(NewKRRenderValue(CreateTextInputStateMap())); + auto state = CreateTextInputStateSnapshot(); + RecordCompleteTextInputState(state); + text_input_state_change_callback_(NewKRRenderValue(CreateTextInputStateMap(state))); } /** @@ -559,7 +606,11 @@ void KRTextFieldView::NotifySelectionChange() { if (!selection_change_callback_) { return; } - selection_change_callback_(NewKRRenderValue(CreateTextInputStateMap())); + auto state = CreateTextInputStateSnapshot(); + if (ConsumeCompleteTextInputState(state)) { + return; + } + selection_change_callback_(NewKRRenderValue(CreateTextInputStateMap(state))); } /** @@ -602,6 +653,9 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { LimitInputContentTextInMaxLength(); drag_entered_ = false; } + if (is_setting_text_input_state_) { + return; + } // Android afterTextChanged 先发带 selection 的完整 state,再发 legacy textDidChange。 // Compose 依赖这个顺序跳过不含 selection 的 fallback;如果反过来, // 新文本会先被配上 (0, 0) 选区回灌 native,导致光标跳到最前面。 @@ -624,6 +678,7 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { */ void KRTextFieldView::OnInputFocus(ArkUI_NodeEvent *event) { pending_blur_request_id_ = 0; + ClearPendingCompleteTextInputStates(); if (input_focus_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(GetContentText()); @@ -639,6 +694,7 @@ void KRTextFieldView::OnInputFocus(ArkUI_NodeEvent *event) { */ void KRTextFieldView::OnInputBlur(ArkUI_NodeEvent *event) { pending_focus_request_id_ = 0; + ClearPendingCompleteTextInputStates(); if (input_blur_callback_) { KRRenderValueMap map; map["text"] = NewKRRenderValue(GetContentText()); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h index 0cc34f4a3..5f7d50102 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/input/KRTextFieldView.h @@ -19,6 +19,7 @@ #include "libohos_render/export/IKRRenderViewExport.h" #include #include +#include #include class KRTextFieldView : public IKRRenderViewExport { @@ -116,6 +117,17 @@ class KRTextFieldView : public IKRRenderViewExport { int GetUTF16Length(const std::string &text); private: + struct TextInputStateSnapshot { + std::string text; + int32_t selection_start = 0; + int32_t selection_end = 0; + + bool HasSameEditingState(const TextInputStateSnapshot &other) const { + return text == other.text && selection_start == other.selection_start && + selection_end == other.selection_end; + } + }; + float font_size_ = 15; // default 15 ArkUI_FontWeight font_weight_ = ARKUI_FONT_WEIGHT_NORMAL; bool focusable_ = true; @@ -132,7 +144,8 @@ class KRTextFieldView : public IKRRenderViewExport { KRRenderCallback text_input_state_change_callback_; // 文本输入状态变化callback(与 Android textInputStateChange 对齐) KRRenderCallback selection_change_callback_; // 选区变化callback(与 Android KRTextFieldView.selectionChangeCallback 对齐) bool auto_hide_KeyBoard_on_ImeAction_ = false; // 在触发各种IME 按钮时是否回收键盘,默认是不回收 - bool is_setting_text_input_state_ = false; // 通过 setTextInputState 主动写入期间,抑制 textInputStateChange 回流防止业务死循环 + bool is_setting_text_input_state_ = false; // 通过 setTextInputState 主动写入期间,抑制原生编辑回调防止业务死循环 + std::deque pending_complete_text_input_states_; // 待配对的 complete -> selection 回调 int64_t pending_focus_request_id_ = 0; int64_t pending_blur_request_id_ = 0; @@ -166,8 +179,8 @@ class KRTextFieldView : public IKRRenderViewExport { * selection 通过 UpdateInputNodeSelectionRange 写入真实 [start, end] 区间 * (TextInput / TextArea 均支持),不再退化为折叠光标。 * - * 主动写入期间通过 is_setting_text_input_state_ 抑制 textInputStateChange 回调, - * 避免业务把状态写回来形成死循环。 + * 主动写入期间通过 is_setting_text_input_state_ 抑制 textInputStateChange + * 与 legacy textDidChange 回调,避免业务把状态写回来形成死循环。 */ void SetTextInputStateInternal(const std::string &json); @@ -178,6 +191,16 @@ class KRTextFieldView : public IKRRenderViewExport { */ KRRenderValueMap CreateTextInputStateMap(); + TextInputStateSnapshot CreateTextInputStateSnapshot(); + + KRRenderValueMap CreateTextInputStateMap(const TextInputStateSnapshot &state); + + void RecordCompleteTextInputState(const TextInputStateSnapshot &state); + + bool ConsumeCompleteTextInputState(const TextInputStateSnapshot &state); + + void ClearPendingCompleteTextInputStates(); + /** * getTextInputState 方法路径:把当前 state 通过 callback 回吐给业务。 */ @@ -191,14 +214,15 @@ class KRTextFieldView : public IKRRenderViewExport { /** * 选区变化事件回调,跨端语义对齐 Android KRTextFieldView.onSelectionChanged。 - * 主动写入期间通过 is_setting_text_input_state_ 抑制。 + * 主动写入期间通过 is_setting_text_input_state_ 抑制;若与刚发布的完整编辑态完全 + * 相同,则属于同一次文本编辑的重复 ArkUI selection 通知,直接吞掉。 */ void NotifySelectionChange(); /** * 处理 ArkUI 原生选区变化事件(NODE_TEXT_INPUT_ON_TEXT_SELECTION_CHANGE / - * NODE_TEXT_AREA_ON_TEXT_SELECTION_CHANGE)。事件中携带 [start, end],我们同时触发 - * selectionChange 与 textInputStateChange(后者会以最新选区重新拼装 state map)。 + * NODE_TEXT_AREA_ON_TEXT_SELECTION_CHANGE)。事件中携带 [start, end],我们通过 + * selectionChange 上报最新完整 state;与刚发布 complete state 等价的通知会被去重。 */ void OnTextSelectionChange(ArkUI_NodeEvent *event); From e50bcefb225e12f0119bfdf1c46b3de3cf17a660 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 12:49:22 +0800 Subject: [PATCH 108/187] fix(android): keep single-run inline boxes atomic (#38) Signed-off-by: Codex-KMP-Developer Co-authored-by: Codex-KMP-Developer --- .../expand/component/KRRichTextView.kt | 74 +++++++++-- .../component/text/KRRichTextBuilder.kt | 77 ++++++++++- .../text/KRInlineBoxSpanStyleTest.kt | 121 ++++++++++++++++++ 3 files changed, 262 insertions(+), 10 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt index 43d2bc9b6..a6d343a0f 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRRichTextView.kt @@ -193,24 +193,80 @@ class KRRichTextView(context: Context) : KRView(context), KRRichTextViewDrawer.C // 2. 计算spanIndex var spanIndex = -1 - val textLayout = textDrawer?.textLayout - val line = textLayout?.getLineForVertical(y) ?: 0 - val lineLeft: Float = textLayout?.getLineLeft(line) ?: Float.MIN_VALUE - val lineRight: Float = textLayout?.getLineRight(line) ?: Float.MAX_VALUE + val textLayout = textDrawer?.textLayout ?: return spanIndex + val line = textLayout.getLineForVertical(y) + val lineLeft = textLayout.getLineLeft(line) + val lineRight = textLayout.getLineRight(line) if (x < lineLeft || x > lineRight) { // 点击区域超出文本区域 spanIndex = -1 } else { - val off = textLayout?.getOffsetForHorizontal(line, x) ?: 0 - (textLayout?.text as? Spanned)?.getSpans(off, off, FontWeightSpan::class.java)?.also { - if (it.isNotEmpty()) { - spanIndex = it[0].index - } + val off = textLayout.getOffsetForHorizontal(line, x) + (textLayout.text as? Spanned)?.also { spanned -> + spanIndex = + textLayout.findSpanIndexAtBoundary(spanned, off, line, x) + ?: spanIndex } } return spanIndex } + private fun Layout.findSpanIndexAtBoundary( + spanned: Spanned, + offset: Int, + touchedLine: Int, + touchX: Float, + ): Int? { + val selectionPath = Path() + val lineClipPath = Path() + val selectionBounds = RectF() + val ranges = + spanned.getSpans(offset, offset, KRInlineBoxAtomicTextSpan::class.java) + .mapNotNull { atomicSpan -> + val start = spanned.getSpanStart(atomicSpan) + val end = spanned.getSpanEnd(atomicSpan) + if (start < 0 || end <= start) return@mapNotNull null + val owner = + spanned.getSpans(start, end, FontWeightSpan::class.java) + .firstOrNull { weightSpan -> + spanned.getSpanStart(weightSpan) <= start && + spanned.getSpanEnd(weightSpan) >= end + } + ?: return@mapNotNull null + val atomicLine = getLineForOffset(start + (end - start - 1) / 2) + selectionPath.reset() + getSelectionPath(start, end, selectionPath) + lineClipPath.reset() + lineClipPath.addRect( + 0f, + getLineTop(atomicLine).toFloat(), + width.toFloat(), + getLineBottom(atomicLine).toFloat(), + Path.Direction.CW, + ) + if (!selectionPath.op(lineClipPath, Path.Op.INTERSECT)) { + return@mapNotNull null + } + selectionPath.computeBounds(selectionBounds, true) + if (selectionBounds.isEmpty) return@mapNotNull null + KRInlineBoxAtomicHitRange( + line = atomicLine, + left = selectionBounds.left, + right = selectionBounds.right, + spanIndex = owner.index, + ) + } + val fallbackSpanIndices = + spanned.getSpans(offset, offset, FontWeightSpan::class.java) + .map(FontWeightSpan::index) + return resolveKRInlineBoxBoundaryHit( + touchedLine = touchedLine, + touchX = touchX, + ranges = ranges, + fallbackSpanIndices = fallbackSpanIndices, + ) + } + private fun initTextLayout(richTextShadow: KRRichTextShadow?) { val textShadow = richTextShadow ?: return val newTextDrawer = tryReMeasureTextLayout(textShadow, layoutParams) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index dbb793387..a2d382c98 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -312,6 +312,46 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (childProps.text.isNotEmpty()) add(childIndex to childProps) } } + val atomicChild = children.singleOrNull() + if ( + shouldAppendInlineBoxGroupAtomically( + childCount = children.size, + onlyChildIsText = atomicChild?.second is TextSpanProps, + onlyChildAdjustsNewline = (atomicChild?.second as? TextSpanProps)?.adjustNewline == true, + ) + ) { + val (childIndex, childProps) = checkNotNull(atomicChild) + val groupStart = length + appendSpan( + spanProps = childProps, + index = index, + childIndex = childIndex, + spanTextRanges = spanTextRanges, + layoutSizeGetter = layoutSizeGetter, + ) + val groupEnd = length + if (groupEnd > groupStart) { + // A one-run inline box is an inline-block. Keeping it as one + // ReplacementSpan makes Android move the whole token to the next + // line instead of splitting the invisible group edges away from + // its text and painting chrome over adjacent content. + applyInlineBoxAtomicTextSpan(groupStart, groupEnd, groupProps.style) + setSpan( + KRInlineBoxSpan(groupProps.style), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + setSpan( + KRInlineBoxSemanticSpan(groupProps.semanticText), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + spanTextRanges.add(SpanTextRange(index, null, groupStart, groupEnd)) + } + return + } val groupStart = length append( INLINE_BOX_LAYOUT_EDGE.toString(), @@ -362,6 +402,12 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { } +internal fun shouldAppendInlineBoxGroupAtomically( + childCount: Int, + onlyChildIsText: Boolean, + onlyChildAdjustsNewline: Boolean, +): Boolean = childCount == 1 && onlyChildIsText && !onlyChildAdjustsNewline + abstract class SpanProps(spanValue: JSONObject) { protected val _text: String = spanValue.optString(KRTextProps.PROP_KEY_TEXT, "") open val text: String get() = _text @@ -696,7 +742,7 @@ private fun SpannableStringBuilder.applyInlineBoxAtomicTextSpan( } } -private class KRInlineBoxAtomicTextSpan( +internal class KRInlineBoxAtomicTextSpan( private val style: KRInlineBoxSpanStyle, ) : ReplacementSpan() { override fun getSize( @@ -736,6 +782,35 @@ private class KRInlineBoxAtomicTextSpan( } } +internal data class KRInlineBoxAtomicHitRange( + val line: Int, + val left: Float, + val right: Float, + val spanIndex: Int, +) + +internal fun resolveKRInlineBoxAtomicHit( + touchedLine: Int, + touchX: Float, + ranges: List, +): Int? = + ranges.firstOrNull { range -> + range.line == touchedLine && + touchX >= minOf(range.left, range.right) && + touchX <= maxOf(range.left, range.right) + }?.spanIndex + +internal fun resolveKRInlineBoxBoundaryHit( + touchedLine: Int, + touchX: Float, + ranges: List, + fallbackSpanIndices: List, +): Int? { + resolveKRInlineBoxAtomicHit(touchedLine, touchX, ranges)?.let { return it } + val atomicOwnerIndices = ranges.mapTo(mutableSetOf(), KRInlineBoxAtomicHitRange::spanIndex) + return fallbackSpanIndices.firstOrNull { it !in atomicOwnerIndices } +} + private fun SpannableStringBuilder.applySlockInlineCodeAtomicTextSpans(start: Int, end: Int) { var index = start var firstAtom = true diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt index f773e2fb0..ed7afef0d 100644 --- a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt @@ -34,4 +34,125 @@ class KRInlineBoxSpanStyleTest { assertEquals(0f, style.paddingStart) assertEquals(0f, style.paddingEnd) } + + @Test + fun singleTextChildUsesAtomicInlineBoxLayout() { + assertEquals( + true, + shouldAppendInlineBoxGroupAtomically( + childCount = 1, + onlyChildIsText = true, + onlyChildAdjustsNewline = false, + ), + ) + assertEquals( + false, + shouldAppendInlineBoxGroupAtomically( + childCount = 2, + onlyChildIsText = true, + onlyChildAdjustsNewline = false, + ), + ) + assertEquals( + false, + shouldAppendInlineBoxGroupAtomically( + childCount = 1, + onlyChildIsText = false, + onlyChildAdjustsNewline = false, + ), + ) + assertEquals( + false, + shouldAppendInlineBoxGroupAtomically( + childCount = 1, + onlyChildIsText = true, + onlyChildAdjustsNewline = true, + ), + ) + } + + @Test + fun atomicInlineBoxHitKeepsClickableIndexAcrossBothHalves() { + val precedingNormalSpanIndex = 0 + val chip = KRInlineBoxAtomicHitRange(line = 1, left = 100f, right = 200f, spanIndex = 1) + val followingNormalSpanIndex = 2 + + assertEquals( + 1, + resolveKRInlineBoxBoundaryHit( + touchedLine = chip.line, + touchX = 110f, + ranges = listOf(chip), + fallbackSpanIndices = listOf(precedingNormalSpanIndex, chip.spanIndex), + ), + ) + assertEquals( + precedingNormalSpanIndex, + resolveKRInlineBoxBoundaryHit( + touchedLine = chip.line, + touchX = 90f, + ranges = listOf(chip), + fallbackSpanIndices = listOf(precedingNormalSpanIndex, chip.spanIndex), + ), + ) + assertEquals( + 1, + resolveKRInlineBoxBoundaryHit( + touchedLine = chip.line, + touchX = 190f, + ranges = listOf(chip), + fallbackSpanIndices = listOf(chip.spanIndex, followingNormalSpanIndex), + ), + ) + assertEquals( + followingNormalSpanIndex, + resolveKRInlineBoxBoundaryHit( + touchedLine = chip.line, + touchX = 210f, + ranges = listOf(chip), + fallbackSpanIndices = listOf(chip.spanIndex, followingNormalSpanIndex), + ), + ) + } + + @Test + fun wrappedAtomicInlineBoxDoesNotStealPreviousLineBoundary() { + val precedingNormalSpanIndex = 0 + val chip = KRInlineBoxAtomicHitRange(line = 1, left = 0f, right = 100f, spanIndex = 1) + + assertEquals( + precedingNormalSpanIndex, + resolveKRInlineBoxBoundaryHit( + touchedLine = 0, + touchX = 300f, + ranges = listOf(chip), + fallbackSpanIndices = listOf(precedingNormalSpanIndex, chip.spanIndex), + ), + ) + } + + @Test + fun adjacentAtomicInlineBoxesResolveTheTouchedSideOfSharedBoundary() { + val left = KRInlineBoxAtomicHitRange(line = 0, left = 0f, right = 100f, spanIndex = 0) + val right = KRInlineBoxAtomicHitRange(line = 0, left = 100f, right = 200f, spanIndex = 1) + + assertEquals( + 0, + resolveKRInlineBoxBoundaryHit( + touchedLine = 0, + touchX = 95f, + ranges = listOf(left, right), + fallbackSpanIndices = listOf(left.spanIndex, right.spanIndex), + ), + ) + assertEquals( + 1, + resolveKRInlineBoxBoundaryHit( + touchedLine = 0, + touchX = 105f, + ranges = listOf(left, right), + fallbackSpanIndices = listOf(left.spanIndex, right.spanIndex), + ), + ) + } } From c0cbd304fd95ae2f71bdb781122d0f693190ee80 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 17:24:00 +0800 Subject: [PATCH 109/187] fix(android): pin atomic inline box chrome (#39) Signed-off-by: Codex-KMP-Developer Co-authored-by: Codex-KMP-Developer --- .../component/text/KRRichTextBuilder.kt | 12 ++- .../component/text/KRRichTextViewDrawer.kt | 80 ++++++++++++++++--- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt index a2d382c98..5e2f4f9eb 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextBuilder.kt @@ -745,6 +745,9 @@ private fun SpannableStringBuilder.applyInlineBoxAtomicTextSpan( internal class KRInlineBoxAtomicTextSpan( private val style: KRInlineBoxSpanStyle, ) : ReplacementSpan() { + internal var measuredWidth: Int = 0 + private set + override fun getSize( paint: Paint, text: CharSequence?, @@ -752,7 +755,10 @@ internal class KRInlineBoxAtomicTextSpan( end: Int, fm: Paint.FontMetricsInt? ): Int { - if (text == null || start >= end) return 0 + if (text == null || start >= end) { + measuredWidth = 0 + return 0 + } fm?.let { it.ascent -= ceil(style.paddingTop).toInt() it.top -= ceil(style.paddingTop).toInt() @@ -761,7 +767,9 @@ internal class KRInlineBoxAtomicTextSpan( } val edgeStart = style.marginStart + style.borderWidth + style.paddingStart val edgeEnd = style.paddingEnd + style.borderWidth + style.marginEnd - return ceil((paint.measureText(text, start, end) + edgeStart + edgeEnd).toDouble()).toInt() + measuredWidth = + ceil((paint.measureText(text, start, end) + edgeStart + edgeEnd).toDouble()).toInt() + return measuredWidth } override fun draw( diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt index b02d5e194..d5b5f2387 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/text/KRRichTextViewDrawer.kt @@ -78,6 +78,9 @@ class KRRichTextViewDrawer(val textLayout: Layout) { style = Paint.Style.STROKE } private val inlineBoxRect = RectF() + private val inlineBoxSelectionPath = Path() + private val inlineBoxLineClipPath = Path() + private val inlineBoxSelectionBounds = RectF() private val wordIterator by lazy(LazyThreadSafetyMode.NONE) { WordIterator(textLayout.text, 0, textLayout.text.length, Locale.getDefault()) @@ -121,6 +124,12 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val end = spanned.getSpanEnd(span) if (start < 0 || end <= start) return@forEach val style = span.style + val atomicSpan = + spanned.getSpans(start, end, KRInlineBoxAtomicTextSpan::class.java) + .firstOrNull { atomicSpan -> + spanned.getSpanStart(atomicSpan) == start && + spanned.getSpanEnd(atomicSpan) == end + } val startLine = textLayout.getLineForOffset((start + 1).coerceAtMost(end - 1)) val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) for (line in startLine..endLine) { @@ -129,19 +138,31 @@ class KRRichTextViewDrawer(val textLayout: Layout) { val segmentStart = max(start, lineStart) val segmentEnd = min(end, lineVisibleEnd) if (segmentEnd <= segmentStart) continue - val startX = max( - textLayout.getPrimaryHorizontal(segmentStart), - textLayout.getSecondaryHorizontal(segmentStart), - ) - // At a run boundary Android's primary caret may use downstream - // affinity and jump across the following span. The upstream - // caret is the actual visual end of this inline group. - val endX = min( - textLayout.getPrimaryHorizontal(segmentEnd), - textLayout.getSecondaryHorizontal(segmentEnd), - ) - val segmentLeft = min(startX, endX) - val segmentRight = max(startX, endX) + val atomicBounds = + atomicSpan?.let { atomicInlineBoxBounds(start, end, line, it) } + val segmentLeft: Float + val segmentRight: Float + if (atomicBounds != null) { + // ReplacementSpan caret affinity can still resolve to adjacent + // text. The selection path keeps the visual anchor, while the + // span's measured width avoids line-end selection expansion. + segmentLeft = atomicBounds.left + segmentRight = atomicBounds.right + } else { + val startX = max( + textLayout.getPrimaryHorizontal(segmentStart), + textLayout.getSecondaryHorizontal(segmentStart), + ) + // At a run boundary Android's primary caret may use downstream + // affinity and jump across the following span. The upstream + // caret is the actual visual end of this inline group. + val endX = min( + textLayout.getPrimaryHorizontal(segmentEnd), + textLayout.getSecondaryHorizontal(segmentEnd), + ) + segmentLeft = min(startX, endX) + segmentRight = max(startX, endX) + } val left = ( segmentLeft + if (segmentStart == start) style.marginStart else 0f ) @@ -182,6 +203,39 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } } + private fun atomicInlineBoxBounds( + start: Int, + end: Int, + line: Int, + atomicSpan: KRInlineBoxAtomicTextSpan, + ): RectF? { + val measuredWidth = atomicSpan.measuredWidth.toFloat() + if (measuredWidth <= 0f) return null + inlineBoxSelectionPath.reset() + textLayout.getSelectionPath(start, end, inlineBoxSelectionPath) + inlineBoxLineClipPath.reset() + inlineBoxLineClipPath.addRect( + 0f, + textLayout.getLineTop(line).toFloat(), + textLayout.width.toFloat(), + textLayout.getLineBottom(line).toFloat(), + Path.Direction.CW, + ) + if (!inlineBoxSelectionPath.op(inlineBoxLineClipPath, Path.Op.INTERSECT)) { + return null + } + inlineBoxSelectionPath.computeBounds(inlineBoxSelectionBounds, true) + if (inlineBoxSelectionBounds.isEmpty) return null + if (textLayout.getParagraphDirection(line) >= 0) { + inlineBoxSelectionBounds.right = + min(textLayout.width.toFloat(), inlineBoxSelectionBounds.left + measuredWidth) + } else { + inlineBoxSelectionBounds.left = + max(0f, inlineBoxSelectionBounds.right - measuredWidth) + } + return inlineBoxSelectionBounds + } + private fun drawSlockInlineCodeChrome(canvas: Canvas, drawFill: Boolean, drawBorder: Boolean) { val spanned = textLayout.text as? Spanned ?: return val spans = spanned.getSpans(0, spanned.length, KRSlockInlineCodeSpan::class.java) From 71227e680595bb381a2cf09a08e6834c1207db8d Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 17:46:49 +0800 Subject: [PATCH 110/187] fix(android): harden render scheduler task queue (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 Co-authored-by: KMP-专家 --- .../scheduler/KuiklyRenderCoreUIScheduler.kt | 198 +++++++++++++----- ...uiklyRenderCoreUISchedulerTaskBatchTest.kt | 136 ++++++++++++ 2 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUISchedulerTaskBatchTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUIScheduler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUIScheduler.kt index 0b0c45f59..4952d6048 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUIScheduler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUIScheduler.kt @@ -31,22 +31,15 @@ import com.tencent.kuikly.core.render.android.expand.KuiklyRenderTracer class KuiklyRenderCoreUIScheduler( private val preRunKuiklyRenderCoreUITask: PreRunKuiklyRenderCoreTask? = null ) : IKuiklyRenderCoreScheduler { - /** - * Context线程上的主线程任务集合 - */ - private var mainThreadTasksOnContextQueue: MutableList? = null - /** - * 主线程上的任务集合 - */ - private var mainThreadTasks = mutableListOf() - /** - * 待批量同步主线程任务任务闭包,用于保证一个runLoop中,不管[scheduleTask]调用多少次,最后只会批量调度一次 - */ - private var needSyncMainQueueTasksBlock : ((sync: Boolean) -> Unit)? = null + private val taskQueue = KuiklyRenderCoreTaskQueue() /* * 需要立即回到主线程执行的同步主线程执行任务闭包 */ - var mainThreadTaskWaitToSyncBlock : (() -> Unit)? = null + var mainThreadTaskWaitToSyncBlock: (() -> Unit)? + get() = taskQueue.peekMainThreadWaitBlock() + set(value) { + taskQueue.replaceMainThreadWaitBlock(value) + } /* * 是否执行主线程任务中 */ @@ -100,7 +93,11 @@ class KuiklyRenderCoreUIScheduler( override fun destroy() { KuiklyRenderLog.i("KuiklyRenderCoreUIScheduler", "--destroy uiScheduler--") + taskQueue.destroy() uiHandler.removeCallbacksAndMessages(null) + viewDidLoadMainThreadTasks.clear() + viewTreeUpdateListener = null + exceptionListener = null } fun setViewTreeUpdateListener(listener: IKuiklyRenderViewTreeUpdateListener) { @@ -112,27 +109,26 @@ class KuiklyRenderCoreUIScheduler( } fun performSyncMainQueueTasksBlockIfNeed(sync: Boolean) { + if (taskQueue.destroyed) return var tracer: KuiklyRenderTracer? = null if (debugLogEnable && logPerformIfNeedCount < UI_SCHEDULER_MAX_LOG_COUNT) { - tracer = KuiklyRenderTracer("invoke needSyncMainQueueTasksBlock $logPerformIfNeedCount isNull=${needSyncMainQueueTasksBlock == null} sync=$sync") + tracer = KuiklyRenderTracer("invoke needSyncMainQueueTasksBlock $logPerformIfNeedCount isNull=${!taskQueue.hasDrainBlock()} sync=$sync") logPerformIfNeedCount++ } - if (needSyncMainQueueTasksBlock != null) { - needSyncMainQueueTasksBlock?.invoke(sync) - needSyncMainQueueTasksBlock = null - } + taskQueue.takeDrainBlock()?.invoke(sync) tracer?.end() } fun performMainThreadTaskWaitToSyncBlockIfNeed() { + if (taskQueue.destroyed) return var tracer: KuiklyRenderTracer? = null if (debugLogEnable && logRunCount < UI_SCHEDULER_MAX_LOG_COUNT) { - tracer = KuiklyRenderTracer("invoke mainThreadTaskWaitToSyncBlock $logRunCount isNull=${mainThreadTaskWaitToSyncBlock == null}") + tracer = KuiklyRenderTracer("invoke mainThreadTaskWaitToSyncBlock $logRunCount isNull=${!taskQueue.hasMainThreadWaitBlock()}") logRunCount++ } - if (mainThreadTaskWaitToSyncBlock != null) { - mainThreadTaskWaitToSyncBlock?.invoke() - mainThreadTaskWaitToSyncBlock = null + val block = taskQueue.takeMainThreadWaitBlock() + if (!taskQueue.destroyed) { + block?.invoke() } tracer?.end() } @@ -140,6 +136,7 @@ class KuiklyRenderCoreUIScheduler( // 首屏完成在执行任务 fun performWhenViewDidLoad(task: KuiklyRenderCoreTask) { assert(isMainThread()) + if (taskQueue.destroyed) return if (viewDidLoad) { task() } else { @@ -149,10 +146,7 @@ class KuiklyRenderCoreUIScheduler( private fun addTaskToMainQueue(task: KuiklyRenderCoreTaskExecutor) { assert(!isMainThread()) - val tasks = mainThreadTasksOnContextQueue ?: mutableListOf().apply { - mainThreadTasksOnContextQueue = this - } - tasks.add(task) + if (!taskQueue.enqueue(task)) return if (task.isUpdateViewTree) { viewTreeUpdateListener?.onUpdateViewTreeEnqueued() } @@ -161,77 +155,75 @@ class KuiklyRenderCoreUIScheduler( private fun setNeedSyncMainQueueTasks() { assert(!isMainThread()) - if (needSyncMainQueueTasksBlock != null) { - return - } + if (taskQueue.destroyed) return if (debugLogEnable && setNeedSyncLogCount < UI_SCHEDULER_MAX_LOG_COUNT) { KuiklyRenderLog.d("KuiklyUIScheduler", "--setNeedSyncMainQueueTasks${setNeedSyncLogCount}--") setNeedSyncLogCount++ } - needSyncMainQueueTasksBlock = { sync -> + val block: (Boolean) -> Unit = syncBlock@ { sync -> + if (taskQueue.destroyed) return@syncBlock assert(!isMainThread()) if (debugLogEnable && needSyncLogCount < UI_SCHEDULER_MAX_LOG_COUNT) { KuiklyRenderLog.d("KuiklyUIScheduler", "--needSyncMainQueueTasksBlock${needSyncLogCount}--") needSyncLogCount++ } preRunKuiklyRenderCoreUITask?.invoke() - val performTasks = mainThreadTasksOnContextQueue - mainThreadTasksOnContextQueue = null - synchronized(this) { - mainThreadTasks.addAll(performTasks?.toList() ?: listOf()) - } + if (!taskQueue.transferContextTasksToMain()) return@syncBlock performOnMainQueueWithTask(sync = sync) { + if (taskQueue.destroyed) return@performOnMainQueueWithTask if (debugLogEnable && performFunLogCount < UI_SCHEDULER_MAX_LOG_COUNT) { KuiklyRenderLog.d("KuiklyUIScheduler", "--performOnMainQueueWithTask:${sync} ${performFunLogCount}--") performFunLogCount++ } - var tasks : List? - synchronized(this) { - tasks = mainThreadTasks.toList() - mainThreadTasks.clear() - } - runMainQueueTasks(tasks) + runMainQueueTasks(taskQueue.takeMainTasks()) } } + if (!taskQueue.installDrainBlock(block)) return KuiklyRenderCoreContextScheduler.scheduleTask { performSyncMainQueueTasksBlockIfNeed(false) } // end task } fun performOnMainQueueWithTask(sync : Boolean, task: ()-> Unit) { + if (taskQueue.destroyed) return var tracer: KuiklyRenderTracer? = null if (debugLogEnable && performCount < UI_SCHEDULER_MAX_LOG_COUNT) { - tracer = KuiklyRenderTracer("performOnMainQueueWithTask $performCount sync=$sync isNull=${mainThreadTaskWaitToSyncBlock == null}") + tracer = KuiklyRenderTracer("performOnMainQueueWithTask $performCount sync=$sync isNull=${!taskQueue.hasMainThreadWaitBlock()}") performCount++ } if (sync) { if (isMainThread()) { - task() + if (!taskQueue.destroyed) task() } else { // 当前子线程等到主线程可能发生死锁,暂用闭包等后面立即回到主线程处理 - mainThreadTaskWaitToSyncBlock = task + taskQueue.setMainThreadWaitBlock(task) } } else { uiHandler.post { - task() + if (!taskQueue.destroyed) task() } } tracer?.end() } - private fun runMainQueueTasks(tasks: List?) { + private fun runMainQueueTasks(tasks: List?) { assert(isMainThread()) { "must call on ui thread" } + if (taskQueue.destroyed) return try { val uiTasks = tasks ?: return isPerformingMainQueueTask = true - for (task in uiTasks) { - task.execute() - if (task.isUpdateViewTree) { - viewTreeUpdateListener?.onUpdateViewTreeFinish() - } - } + executeKuiklyRenderCoreTaskBatch( + tasks = uiTasks, + onNullTask = { index -> + KuiklyRenderLog.e( + "KuiklyRenderCoreUIScheduler", + "skip null main queue task index=$index size=${uiTasks.size}" + ) + }, + onUpdateViewTreeFinish = { viewTreeUpdateListener?.onUpdateViewTreeFinish() } + ) isPerformingMainQueueTask = false } catch (e : Exception) { exceptionListener?.onRenderException(e, ErrorReason.UPDATE_VIEW_TREE) @@ -252,7 +244,9 @@ class KuiklyRenderCoreUIScheduler( // perform all wait to viewDidLoad tasks private fun performViewDidLoadTasksIfNeed() { + if (taskQueue.destroyed) return performOnMainQueueWithTask(sync = false) { + if (taskQueue.destroyed) return@performOnMainQueueWithTask for (task in viewDidLoadMainThreadTasks.toList()) { task() } @@ -270,6 +264,106 @@ class KuiklyRenderCoreUIScheduler( } +/** + * Owns all render-task queue state shared by context/native producers and the Android main thread. + * A non-main-thread assertion does not imply a single producer, so every mutation must use [lock]. + */ +internal class KuiklyRenderCoreTaskQueue { + private val lock = Any() + private var contextTasks: MutableList? = null + private val mainTasks = mutableListOf() + private var drainBlock: ((Boolean) -> Unit)? = null + private var mainThreadWaitBlock: (() -> Unit)? = null + + @Volatile + var destroyed = false + private set + + fun enqueue(task: KuiklyRenderCoreTaskExecutor): Boolean = synchronized(lock) { + if (destroyed) return@synchronized false + val tasks = contextTasks ?: mutableListOf().also { + contextTasks = it + } + tasks.add(task) + true + } + + fun installDrainBlock(block: (Boolean) -> Unit): Boolean = synchronized(lock) { + if (destroyed || drainBlock != null) return@synchronized false + drainBlock = block + true + } + + fun hasDrainBlock(): Boolean = synchronized(lock) { drainBlock != null } + + fun takeDrainBlock(): ((Boolean) -> Unit)? = synchronized(lock) { + drainBlock.also { drainBlock = null } + } + + fun transferContextTasksToMain(): Boolean = synchronized(lock) { + if (destroyed) return@synchronized false + mainTasks.addAll(contextTasks?.toList().orEmpty()) + contextTasks = null + true + } + + fun takeMainTasks(): List = synchronized(lock) { + if (destroyed) return@synchronized emptyList() + mainTasks.toList().also { mainTasks.clear() } + } + + fun hasMainThreadWaitBlock(): Boolean = synchronized(lock) { mainThreadWaitBlock != null } + + fun peekMainThreadWaitBlock(): (() -> Unit)? = synchronized(lock) { mainThreadWaitBlock } + + fun setMainThreadWaitBlock(block: () -> Unit): Boolean = synchronized(lock) { + if (destroyed) return@synchronized false + mainThreadWaitBlock = block + true + } + + fun replaceMainThreadWaitBlock(block: (() -> Unit)?): Boolean = synchronized(lock) { + if (destroyed && block != null) return@synchronized false + mainThreadWaitBlock = block + true + } + + fun takeMainThreadWaitBlock(): (() -> Unit)? = synchronized(lock) { + mainThreadWaitBlock.also { mainThreadWaitBlock = null } + } + + fun destroy() { + synchronized(lock) { + destroyed = true + contextTasks?.clear() + contextTasks = null + mainTasks.clear() + drainBlock = null + mainThreadWaitBlock = null + } + } +} + +internal fun executeKuiklyRenderCoreTaskBatch( + tasks: List, + onNullTask: (Int) -> Unit = {}, + onUpdateViewTreeFinish: () -> Unit = {} +): Int { + var executed = 0 + tasks.forEachIndexed { index, task -> + if (task == null) { + onNullTask(index) + return@forEachIndexed + } + task.execute() + executed++ + if (task.isUpdateViewTree) { + onUpdateViewTreeFinish() + } + } + return executed +} + /** * 执行任务包装类,用于区分是否为更新 UI 的任务 */ diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUISchedulerTaskBatchTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUISchedulerTaskBatchTest.kt new file mode 100644 index 000000000..1e59bb0ab --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreUISchedulerTaskBatchTest.kt @@ -0,0 +1,136 @@ +package com.tencent.kuikly.core.render.android.scheduler + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class KuiklyRenderCoreUISchedulerTaskBatchTest { + + @Test + fun nullQueueEntriesAreSkippedWithoutDroppingValidTasks() { + val executed = mutableListOf() + val nullIndexes = mutableListOf() + var updateViewTreeFinishCount = 0 + + val executedCount = + executeKuiklyRenderCoreTaskBatch( + tasks = + listOf( + KuiklyRenderCoreTaskExecutor(Runnable { executed += "first" }, false), + null, + KuiklyRenderCoreTaskExecutor(Runnable { executed += "second" }, true) + ), + onNullTask = nullIndexes::add, + onUpdateViewTreeFinish = { updateViewTreeFinishCount++ } + ) + + assertEquals(listOf("first", "second"), executed) + assertEquals(listOf(1), nullIndexes) + assertEquals(2, executedCount) + assertEquals(1, updateViewTreeFinishCount) + } + + @Test + fun taskFailureStillStopsTheBatchForTheSchedulerExceptionBoundary() { + val executed = mutableListOf() + + val error = + runCatching { + executeKuiklyRenderCoreTaskBatch( + tasks = + listOf( + KuiklyRenderCoreTaskExecutor(Runnable { executed += "first" }, false), + KuiklyRenderCoreTaskExecutor(Runnable { error("boom") }, false), + KuiklyRenderCoreTaskExecutor(Runnable { executed += "third" }, false) + ) + ) + }.exceptionOrNull() + + assertEquals("boom", error?.message) + assertEquals(listOf("first"), executed) + } + + @Test + fun concurrentProducersDoNotCorruptOrDropQueueEntries() { + val queue = KuiklyRenderCoreTaskQueue() + val producerCount = 4 + val tasksPerProducer = 500 + val start = CountDownLatch(1) + val done = CountDownLatch(producerCount) + val executor = Executors.newFixedThreadPool(producerCount) + val allAccepted = AtomicBoolean(true) + + repeat(producerCount) { producer -> + executor.execute { + try { + start.await() + repeat(tasksPerProducer) { index -> + if (!queue.enqueue( + KuiklyRenderCoreTaskExecutor( + Runnable {}, + (producer + index) % 2 == 0 + ) + )) { + allAccepted.set(false) + } + } + } finally { + done.countDown() + } + } + } + + start.countDown() + assertTrue(done.await(5, TimeUnit.SECONDS)) + executor.shutdownNow() + + assertTrue(allAccepted.get()) + assertTrue(queue.transferContextTasksToMain()) + val tasks = queue.takeMainTasks() + assertEquals(producerCount * tasksPerProducer, tasks.size) + assertTrue(tasks.all { it != null }) + } + + @Test + fun destroyClearsPendingWorkAndRejectsNewWork() { + val queue = KuiklyRenderCoreTaskQueue() + var drained = false + var waited = false + + assertTrue(queue.enqueue(KuiklyRenderCoreTaskExecutor(Runnable {}, false))) + assertTrue(queue.installDrainBlock { drained = true }) + assertTrue(queue.setMainThreadWaitBlock { waited = true }) + assertTrue(queue.transferContextTasksToMain()) + + queue.destroy() + + assertTrue(queue.destroyed) + assertFalse(queue.enqueue(KuiklyRenderCoreTaskExecutor(Runnable {}, false))) + assertFalse(queue.installDrainBlock { drained = true }) + assertFalse(queue.setMainThreadWaitBlock { waited = true }) + assertFalse(queue.transferContextTasksToMain()) + assertTrue(queue.takeMainTasks().isEmpty()) + queue.takeDrainBlock()?.invoke(false) + queue.takeMainThreadWaitBlock()?.invoke() + assertFalse(drained) + assertFalse(waited) + } + + @Test + fun takingDrainBlockAllowsConcurrentFollowUpBatchToSchedule() { + val queue = KuiklyRenderCoreTaskQueue() + val first: (Boolean) -> Unit = {} + val second: (Boolean) -> Unit = {} + + assertTrue(queue.installDrainBlock(first)) + assertFalse(queue.installDrainBlock(second)) + assertTrue(queue.takeDrainBlock() === first) + assertTrue(queue.installDrainBlock(second)) + assertTrue(queue.takeDrainBlock() === second) + } +} From 820e80673afab67dc58e123dc9a135d4c882d9db Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 21:36:10 +0800 Subject: [PATCH 111/187] fix(ios): align numeric inline box chrome (#40) * fix(ios): balance numeric inline box edges Signed-off-by: Codex-KMP-Developer * fix(ios): anchor numeric inline box chrome Signed-off-by: Codex-KMP-Developer * fix(ios): align numeric chip spacing Signed-off-by: Codex-KMP-Developer --------- Signed-off-by: Codex-KMP-Developer Co-authored-by: Codex-KMP-Developer --- .../Extension/AdvancedComps/KRRichTextView.m | 25 ++++++++++++-- core-render-ios/Extension/Vendor/KRLabel.m | 33 ++++++++++++++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index c2aa6323d..0197f74db 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -30,6 +30,19 @@ static const CGFloat kKRSlockInlineCodeLineHeightRatio = 1.5; static const NSUInteger kKRSlockInlineCodeAtomizeThreshold = 16; +static BOOL KRIsNumericReferenceInlineBox(NSString *semanticText) { + if (semanticText.length < 2 || [semanticText characterAtIndex:0] != '#') { + return NO; + } + for (NSUInteger index = 1; index < semanticText.length; index++) { + unichar character = [semanticText characterAtIndex:index]; + if (character < '0' || character > '9') { + return NO; + } + } + return YES; +} + @interface KRInlineBoxAttachment : NSTextAttachment @property (nonatomic, copy) NSString *originalText; @@ -728,7 +741,17 @@ - (NSMutableAttributedString *)p_createInlineBoxGroupAttributedStringWithSpan:(N spanIndex:(NSInteger)spanIndex { NSArray *children = span[@"inlineBoxChildren"]; if (children.count == 0) return [NSMutableAttributedString new]; + NSString *semantic = span[@"inlineBoxSemanticText"]; + BOOL anchorNumericReference = + [semantic isKindOfClass:[NSString class]] && KRIsNumericReferenceInlineBox(semantic); NSMutableDictionary *style = [self p_inlineBoxStyleFromSpan:span]; + if (anchorNumericReference) { + // React's MSG_REF_CHIP has px-1 padding but no horizontal margin. + // Keep generic inline-box spacing unchanged and align only exact #digits refs. + style[@"numericReferenceAnchoredChrome"] = @YES; + style[@"marginStart"] = @0.0; + style[@"marginEnd"] = @0.0; + } NSMutableDictionary *base = [(_props ?: @{}) mutableCopy]; UIFont *baseFont = [KRConvertUtil UIFont:base] ?: [UIFont systemFontOfSize:15.0]; CGFloat maxContentHeight = baseFont.lineHeight; @@ -819,10 +842,8 @@ - (NSMutableAttributedString *)p_createInlineBoxGroupAttributedStringWithSpan:(N paddingBottom:paddingBottom borderWidth:borderWidth]; [group appendAttributedString:[NSAttributedString attributedStringWithAttachment:trailing]]; - NSRange range = NSMakeRange(0, group.length); [group addAttribute:KRInlineBoxStyleAttributeName value:style range:range]; - NSString *semantic = span[@"inlineBoxSemanticText"]; if ([semantic isKindOfClass:[NSString class]] && semantic.length > 0) { [group addAttribute:KRInlineBoxSemanticAttributeName value:semantic range:range]; } diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index dcb77a6fd..326892856 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -623,12 +623,37 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat borderWidth = [style[@"borderWidth"] doubleValue]; CGFloat paddingTop = [style[@"paddingTop"] doubleValue]; CGFloat paddingBottom = [style[@"paddingBottom"] doubleValue]; - // The edge attachments already reserve margin in TextKit's layout - // advance. Keep that advance inside the painted group fragment; - // trimming it here leaves transparent white notches immediately - // before and after an otherwise continuous bordered inline box. CGFloat left = CGRectGetMinX(bounds) + origin.x; CGFloat right = CGRectGetMaxX(bounds) + origin.x; + if ([style[@"numericReferenceAnchoredChrome"] boolValue] && runRange.length >= 2) { + NSUInteger leadingCharacterIndex = runRange.location; + NSUInteger trailingCharacterIndex = NSMaxRange(runRange) - 1; + NSTextAttachment *leadingAttachment = [textStorage attribute:NSAttachmentAttributeName + atIndex:leadingCharacterIndex + effectiveRange:NULL]; + NSTextAttachment *trailingAttachment = [textStorage attribute:NSAttachmentAttributeName + atIndex:trailingCharacterIndex + effectiveRange:NULL]; + NSRange leadingGlyphRange = [self glyphRangeForCharacterRange:NSMakeRange(leadingCharacterIndex, 1) + actualCharacterRange:NULL]; + NSRange trailingGlyphRange = [self glyphRangeForCharacterRange:NSMakeRange(trailingCharacterIndex, 1) + actualCharacterRange:NULL]; + BOOL segmentOwnsEdges = leadingAttachment && trailingAttachment && + NSIntersectionRange(segment, leadingGlyphRange).length > 0 && + NSIntersectionRange(segment, trailingGlyphRange).length > 0; + if (segmentOwnsEdges) { + // Underline decoration inflates boundingRectForGlyphRange past + // the trailing attachment. Anchor numeric chip chrome to the + // actual edge attachments instead. + CGFloat marginStart = [style[@"marginStart"] doubleValue]; + CGFloat marginEnd = [style[@"marginEnd"] doubleValue]; + CGPoint leadingLocation = [self locationForGlyphAtIndex:leadingGlyphRange.location]; + CGPoint trailingLocation = [self locationForGlyphAtIndex:trailingGlyphRange.location]; + left = leadingLocation.x + origin.x + marginStart; + right = trailingLocation.x + origin.x + + CGRectGetWidth(trailingAttachment.bounds) - marginEnd; + } + } if (right <= left) return; CGFloat boxHeight = [style[@"boxHeight"] doubleValue]; if (boxHeight <= 0) { From d869f46f21ce1e25a4c984b2670e92fdfc12ec4f Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Mon, 13 Jul 2026 22:45:59 +0800 Subject: [PATCH 112/187] refactor(ios): generalize inline box edge anchoring (#43) Signed-off-by: Codex-KMP-Developer Co-authored-by: Codex-KMP-Developer --- .../Extension/AdvancedComps/KRRichTextView.m | 22 ------------------ core-render-ios/Extension/Vendor/KRLabel.m | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m index 0197f74db..24ba88db4 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -30,19 +30,6 @@ static const CGFloat kKRSlockInlineCodeLineHeightRatio = 1.5; static const NSUInteger kKRSlockInlineCodeAtomizeThreshold = 16; -static BOOL KRIsNumericReferenceInlineBox(NSString *semanticText) { - if (semanticText.length < 2 || [semanticText characterAtIndex:0] != '#') { - return NO; - } - for (NSUInteger index = 1; index < semanticText.length; index++) { - unichar character = [semanticText characterAtIndex:index]; - if (character < '0' || character > '9') { - return NO; - } - } - return YES; -} - @interface KRInlineBoxAttachment : NSTextAttachment @property (nonatomic, copy) NSString *originalText; @@ -742,16 +729,7 @@ - (NSMutableAttributedString *)p_createInlineBoxGroupAttributedStringWithSpan:(N NSArray *children = span[@"inlineBoxChildren"]; if (children.count == 0) return [NSMutableAttributedString new]; NSString *semantic = span[@"inlineBoxSemanticText"]; - BOOL anchorNumericReference = - [semantic isKindOfClass:[NSString class]] && KRIsNumericReferenceInlineBox(semantic); NSMutableDictionary *style = [self p_inlineBoxStyleFromSpan:span]; - if (anchorNumericReference) { - // React's MSG_REF_CHIP has px-1 padding but no horizontal margin. - // Keep generic inline-box spacing unchanged and align only exact #digits refs. - style[@"numericReferenceAnchoredChrome"] = @YES; - style[@"marginStart"] = @0.0; - style[@"marginEnd"] = @0.0; - } NSMutableDictionary *base = [(_props ?: @{}) mutableCopy]; UIFont *baseFont = [KRConvertUtil UIFont:base] ?: [UIFont systemFontOfSize:15.0]; CGFloat maxContentHeight = baseFont.lineHeight; diff --git a/core-render-ios/Extension/Vendor/KRLabel.m b/core-render-ios/Extension/Vendor/KRLabel.m index 326892856..611f46d77 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -625,7 +625,7 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi CGFloat paddingBottom = [style[@"paddingBottom"] doubleValue]; CGFloat left = CGRectGetMinX(bounds) + origin.x; CGFloat right = CGRectGetMaxX(bounds) + origin.x; - if ([style[@"numericReferenceAnchoredChrome"] boolValue] && runRange.length >= 2) { + if (runRange.length >= 2) { NSUInteger leadingCharacterIndex = runRange.location; NSUInteger trailingCharacterIndex = NSMaxRange(runRange) - 1; NSTextAttachment *leadingAttachment = [textStorage attribute:NSAttachmentAttributeName @@ -642,16 +642,21 @@ - (void)kr_drawInlineBoxChromeForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoi NSIntersectionRange(segment, leadingGlyphRange).length > 0 && NSIntersectionRange(segment, trailingGlyphRange).length > 0; if (segmentOwnsEdges) { - // Underline decoration inflates boundingRectForGlyphRange past - // the trailing attachment. Anchor numeric chip chrome to the - // actual edge attachments instead. - CGFloat marginStart = [style[@"marginStart"] doubleValue]; - CGFloat marginEnd = [style[@"marginEnd"] doubleValue]; CGPoint leadingLocation = [self locationForGlyphAtIndex:leadingGlyphRange.location]; CGPoint trailingLocation = [self locationForGlyphAtIndex:trailingGlyphRange.location]; - left = leadingLocation.x + origin.x + marginStart; - right = trailingLocation.x + origin.x + - CGRectGetWidth(trailingAttachment.bounds) - marginEnd; + CGFloat attachmentLeft = leadingLocation.x + origin.x; + CGFloat attachmentRight = trailingLocation.x + origin.x + + CGRectGetWidth(trailingAttachment.bounds); + BOOL decorationEscapesEdges = left < attachmentLeft || right > attachmentRight; + if (decorationEscapesEdges) { + // Edge attachments define the group's horizontal layout advance. + // Use them only when decoration inflates the glyph bounds, keeping + // unaffected inline boxes on the existing painter pixel-for-pixel. + CGFloat marginStart = [style[@"marginStart"] doubleValue]; + CGFloat marginEnd = [style[@"marginEnd"] doubleValue]; + left = attachmentLeft + marginStart; + right = attachmentRight - marginEnd; + } } } if (right <= left) return; From 996a20fdba076e9f5381eac4cf3f3dd6a95a9d58 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Tue, 14 Jul 2026 00:44:02 +0800 Subject: [PATCH 113/187] fix(compose): clear stale lazy scroll offset guards (#42) * fix(compose): clear stale lazy scroll offset guards Signed-off-by: artin * fix(compose): make lazy offset guards one-shot Signed-off-by: artin --------- Signed-off-by: artin --- .../compose/gestures/KuiklyScrollInfo.kt | 12 +++++ .../compose/ui/layout/SubcomposeLayout.kt | 14 +++--- .../compose/gestures/KuiklyScrollInfoTest.kt | 45 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 837d857be..7a36307b8 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -46,6 +46,18 @@ class KuiklyScrollInfo { */ var ignoreScrollOffset: IntOffset? = null + internal fun consumeIgnoredScrollOffset( + offsetX: Float, + offsetY: Float, + epsilon: Double, + ): Boolean { + val ignoredOffset = ignoreScrollOffset ?: return false + val matched = kotlin.math.abs(ignoredOffset.x - offsetX) <= epsilon && + kotlin.math.abs(ignoredOffset.y - offsetY) <= epsilon + ignoreScrollOffset = null + return matched + } + /** * Scroll view instance */ diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index fe889cb1d..56ebc1c53 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -332,14 +332,12 @@ fun SubcomposeLayout( (scrollableState as? DrawerInternalPagerState)?.onNativeContentOffsetChanged(offset) kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false - if (kuiklyInfo.ignoreScrollOffset != null) { - val ignoreOffset = kuiklyInfo.ignoreScrollOffset!! - val epsilon = 0.5 * kuiklyInfo.getDensity() // 使用 0.5dp 作为误差值 - val matched = abs(ignoreOffset.x.minus(scaleParams.offsetX)) <= epsilon - && abs(ignoreOffset.y.minus(scaleParams.offsetY)) <= epsilon - if (matched) { - kuiklyInfo.ignoreScrollOffset = null - } + if (kuiklyInfo.consumeIgnoredScrollOffset( + offsetX = scaleParams.offsetX, + offsetY = scaleParams.offsetY, + epsilon = 0.5 * kuiklyInfo.getDensity(), + ) + ) { return@scroll } diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt new file mode 100644 index 000000000..5b5d4a8fd --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt @@ -0,0 +1,45 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.gestures + +import com.tencent.kuikly.compose.ui.unit.IntOffset +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KuiklyScrollInfoTest { + @Test + fun mismatchedProgrammaticCallbackClearsGuardAndProceeds() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 120) + } + + assertFalse(info.consumeIgnoredScrollOffset(offsetX = 0f, offsetY = 118f, epsilon = 0.5)) + assertNull(info.ignoreScrollOffset) + assertFalse(info.consumeIgnoredScrollOffset(offsetX = 0f, offsetY = 220f, epsilon = 0.5)) + } + + @Test + fun matchingProgrammaticCallbackClearsGuardAndIsSkipped() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 120) + } + + assertTrue(info.consumeIgnoredScrollOffset(offsetX = 0f, offsetY = 120f, epsilon = 0.5)) + assertNull(info.ignoreScrollOffset) + } +} From ba0f19fbe848c731bc2f6fdd53e0d3ca707aad6a Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Tue, 14 Jul 2026 14:24:01 +0800 Subject: [PATCH 114/187] fix(android): preserve text field line height on first input (#44) Signed-off-by: Codex-Kuikly-KMP Co-authored-by: Codex-Kuikly-KMP --- .../expand/component/KRTextFieldView.kt | 26 ++++++++++------ .../TextFieldLineHeightSpanPolicyTest.kt | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextFieldLineHeightSpanPolicyTest.kt diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt index 6165b9267..de0c38e44 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRTextFieldView.kt @@ -66,6 +66,10 @@ import com.tencent.kuikly.core.render.android.export.IKuiklyRenderViewExport import com.tencent.kuikly.core.render.android.export.KuiklyRenderCallback import org.json.JSONObject +// POINT_POINT moves past the first insertion when the editor is empty. MARK_POINT +// expands over it, so the first glyph is measured with the configured line height. +internal const val TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS = Spanned.SPAN_INCLUSIVE_INCLUSIVE + /** * KTV单行输入组件 */ @@ -320,9 +324,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : val text = params ?: KRCssConst.EMPTY_STRING lineHeightSpan?.let { span -> val spannable = SpannableString(text) - if (text.isNotEmpty()) { - spannable.setSpan(span, 0, text.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE) - } + spannable.setSpan(span, 0, text.length, TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS) super.setText(spannable, BufferType.EDITABLE) } ?: super.setText(text, BufferType.EDITABLE) setSelection(getText()?.length ?: 0) @@ -719,11 +721,13 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : setInputEditorAdapterIfNeed() lineHeightSpan?.let { span -> val spannable = SpannableString(limitedRawText) - if (limitedRawText.isNotEmpty()) { - spannable.setSpan(span, 0, limitedRawText.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE) - } + spannable.setSpan(span, 0, limitedRawText.length, TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS) super.setText(spannable, BufferType.EDITABLE) } ?: super.setText(limitedRawText, BufferType.EDITABLE) + // A custom Editable.Factory may rebuild the source and drop spans. + // Reattach after TextView creates its final Editable while the + // programmatic-state watcher is intentionally suppressed. + editableText?.also(::ensureLineHeightSpan) applyEmojiSpans(editableText) // 程序化文本也可能被长度限制截断,需要基于实际文本长度调整 selection val actualLength = editableText?.length ?: 0 @@ -1017,11 +1021,16 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : } val outputText = textPostProcessorAdapter.onTextPostProcess(kuiklyRenderContext, TextPostProcessorInput(textPostProcessor, source, tp)).text - return if (outputText is Editable) { + val editable = if (outputText is Editable) { outputText } else { SpannableStringBuilder(source) } + // TextView measures the Editable returned here. Attach the + // global line-height span before setText builds its layout; + // post-set span mutation may only invalidate a fixed layout. + this@KRTextFieldView.ensureLineHeightSpan(editable) + return editable } }) } @@ -1057,8 +1066,7 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : val span = lineHeightSpan ?: return text.apply { if (getSpanStart(span) != 0 || getSpanEnd(span) != length) { - // range changed, call setSpan to update - setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE) + setSpan(span, 0, length, TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS) } } } diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextFieldLineHeightSpanPolicyTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextFieldLineHeightSpanPolicyTest.kt new file mode 100644 index 000000000..2efb710ea --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/TextFieldLineHeightSpanPolicyTest.kt @@ -0,0 +1,30 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.expand.component + +import android.text.Spanned +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class TextFieldLineHeightSpanPolicyTest { + + @Test + fun emptyEditorSpanIncludesFirstInsertion() { + assertEquals(Spanned.SPAN_MARK_POINT, TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS) + assertNotEquals(Spanned.SPAN_POINT_POINT, TEXT_FIELD_LINE_HEIGHT_SPAN_FLAGS) + } +} From 35290a1762a92f657451baf57de2379b4a22fd92 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Tue, 14 Jul 2026 14:48:16 +0800 Subject: [PATCH 115/187] feat(compose): allow pull refresh without held inset (#45) Signed-off-by: Android-Developer-1 Co-authored-by: Android-Developer-1 --- .../kuikly/compose/material3/PullToRefresh.kt | 52 ++++++++++++---- .../material3/PullToRefreshStateTest.kt | 59 +++++++++++++++++++ docs/Compose/list-and-scroll.md | 3 +- 3 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt index 3eb190d1f..f4113627f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt @@ -175,6 +175,9 @@ class PullToRefreshState( * @param topInset Extra top inset for overlay header (e.g. collapsing HeaderBar). * Pass the header's maximum height, not its animated height. * @param refreshThreshold Threshold to trigger refresh + * @param holdRefreshInset Whether the list should keep [refreshThreshold] top inset while + * [state] is refreshing. Disable this when refresh progress is rendered outside the list and + * existing content must return to its resting position immediately after the pull is released. * @param content Custom refresh indicator content */ fun LazyListScope.pullToRefreshItem( @@ -184,6 +187,7 @@ fun LazyListScope.pullToRefreshItem( modifier: Modifier = Modifier, topInset: Dp = 0.dp, refreshThreshold: Dp = 80.dp, + holdRefreshInset: Boolean = true, content: @Composable ( pullProgress: Float, isRefreshing: Boolean, @@ -203,6 +207,7 @@ fun LazyListScope.pullToRefreshItem( modifier = modifier, topInset = topInset, refreshThreshold = refreshThreshold, + holdRefreshInset = holdRefreshInset, content = content ) } @@ -220,6 +225,7 @@ internal fun PullToRefreshItem( modifier: Modifier = Modifier, topInset: Dp = 0.dp, refreshThreshold: Dp = 80.dp, + holdRefreshInset: Boolean = true, content: @Composable ( pullProgress: Float, isRefreshing: Boolean, @@ -290,7 +296,12 @@ internal fun PullToRefreshItem( "progress=$progress thresholdPx=$refreshThresholdPx" } state.updatePullState(PullState.PULLING) - scrollView?.setContentInsetWhenEndDrag(top = refreshThresholdLogical) + scrollView?.setContentInsetWhenEndDrag( + top = pullRefreshEndDragInset( + holdRefreshInset = holdRefreshInset, + refreshThreshold = refreshThresholdLogical + ) + ) } } PullState.PULLING -> { @@ -305,11 +316,17 @@ internal fun PullToRefreshItem( } } else { // Released while pulling, start refresh + val releasedState = pullStateAfterRefreshRelease(holdRefreshInset) pullToRefreshLog { - "PULLING -> REFRESHING (release): offset=$contentOffset " + - "pullDistance=$pullDistance" + "PULLING -> $releasedState (release): offset=$contentOffset " + + "pullDistance=$pullDistance holdRefreshInset=$holdRefreshInset" + } + state.updatePullState(releasedState) + if (!holdRefreshInset) { + state.updateProgress(0f) + scrollView?.setContentInsetWhenEndDrag(top = 0f) + scrollView?.setContentInset(top = 0f, animated = false) } - state.updatePullState(PullState.REFRESHING) updatedOnRefresh() } } @@ -324,13 +341,19 @@ internal fun PullToRefreshItem( } // Handle inset changes based on pull state - LaunchedEffect(state.pullState) { + LaunchedEffect(state.pullState, holdRefreshInset) { val scrollView = scrollState.kuiklyInfo.scrollView val isDragging = scrollView?.isDragging == true when (state.pullState) { PullState.REFRESHING -> { - pullToRefreshLog { "apply inset REFRESHING top=$refreshThresholdLogical animated=true" } - scrollView?.setContentInset(top = refreshThresholdLogical, animated = true) + if (holdRefreshInset) { + pullToRefreshLog { "apply inset REFRESHING top=$refreshThresholdLogical animated=true" } + scrollView?.setContentInset(top = refreshThresholdLogical, animated = true) + } else { + pullToRefreshLog { "skip inset REFRESHING holdRefreshInset=false" } + scrollView?.setContentInsetWhenEndDrag(top = 0f) + scrollView?.setContentInset(top = 0f, animated = false) + } } PullState.IDLE -> { // Never apply contentInset while dragging: @@ -363,8 +386,8 @@ internal fun PullToRefreshItem( } // Sync external refresh state - LaunchedEffect(state.isRefreshing) { - if (state.isRefreshing) { + LaunchedEffect(state.isRefreshing, holdRefreshInset) { + if (state.isRefreshing && holdRefreshInset) { if (state.pullState != PullState.REFRESHING) { state.updatePullState(PullState.REFRESHING) } @@ -389,6 +412,15 @@ internal fun PullToRefreshItem( } } +internal fun pullStateAfterRefreshRelease(holdRefreshInset: Boolean): PullState = + if (holdRefreshInset) PullState.REFRESHING else PullState.IDLE + +internal fun pullRefreshEndDragInset( + holdRefreshInset: Boolean, + refreshThreshold: Float +): Float = + if (holdRefreshInset) refreshThreshold.coerceAtLeast(0f) else 0f + /** * Default refresh indicator */ @@ -418,4 +450,4 @@ private fun DefaultRefreshIndicator( modifier = Modifier.padding(16.dp) ) } -} \ No newline at end of file +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt new file mode 100644 index 000000000..51bd76704 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt @@ -0,0 +1,59 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2026 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.material3 + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PullToRefreshStateTest { + @Test + fun releaseKeepsRefreshingStateWhenInsetIsHeld() { + assertEquals( + PullState.REFRESHING, + pullStateAfterRefreshRelease(holdRefreshInset = true) + ) + } + + @Test + fun releaseReturnsIdleWhenInsetIsNotHeld() { + assertEquals( + PullState.IDLE, + pullStateAfterRefreshRelease(holdRefreshInset = false) + ) + } + + @Test + fun thresholdCrossingDoesNotScheduleEndDragInsetWhenHoldIsDisabled() { + assertEquals( + 0f, + pullRefreshEndDragInset( + holdRefreshInset = false, + refreshThreshold = 80f + ) + ) + } + + @Test + fun thresholdCrossingKeepsLegacyEndDragInsetByDefault() { + assertEquals( + 80f, + pullRefreshEndDragInset( + holdRefreshInset = true, + refreshThreshold = 80f + ) + ) + } +} diff --git a/docs/Compose/list-and-scroll.md b/docs/Compose/list-and-scroll.md index eb399c801..4fb477c5b 100644 --- a/docs/Compose/list-and-scroll.md +++ b/docs/Compose/list-and-scroll.md @@ -174,6 +174,7 @@ fun PullToRefreshList(data: List) { |------|--------|------| | `topInset` | `0.dp` | overlay HeaderBar 等场景下,PTR item 顶部的额外留白。传 **header 展开时的最大高度**,不是动画中的实时高度。框架会在 PTR item 内部自动应用等效 `padding(top)`,**请勿**再在 `modifier` 上重复设置 `padding(top = ...)`。未设置时行为与原来一致。 | | `refreshThreshold` | `80.dp` | 触发刷新的下拉距离 | +| `holdRefreshInset` | `true` | 刷新期间是否持续保留 `refreshThreshold` 高度的顶部 content inset。若刷新进度由列表外的固定 Header 展示,并要求松手后列表内容立即回到原位,可设为 `false`;拖动阶段的 progress 与阈值触发语义不变。 | #### overlay HeaderBar(`topInset`) @@ -386,5 +387,3 @@ fun SimpleStaggeredGrid(items: List) { - 预加载:`beyondViewportPageCount` / `beyondBoundsItemCount` 不宜设置过大,一般控制在小范围内(例如 1~3 页、4~10 个 item),否则会明显增加首帧时间和内存占用。 - 嵌套滚动:`LazyColumn` / `LazyRow` 与 `Pager`、外层滚动容器嵌套时,优先使用 `Modifier.nestedScroll`、`Modifier.bouncesEnable` 等官方/Kuikly 提供的能力,不建议自行拦截手势事件。 - 状态管理:业务状态尽量 hoist 到列表外(ViewModel / 上层 Composable),避免在 `items` 内部直接 `remember { mutableStateOf(...) }` 保存关键状态,以免 item 复用、插入/删除时出现错乱。 - - From aec013d5f46141d3f2704049e8a811b997808a2d Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Wed, 15 Jul 2026 10:35:53 +0800 Subject: [PATCH 116/187] fix(compose): refresh pull config without replacing state (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 Co-authored-by: KMP-专家 --- .../kuikly/compose/material3/PullToRefresh.kt | 169 ++++++++++++--- .../material3/PullToRefreshStateTest.kt | 202 ++++++++++++++++++ docs/Compose/list-and-scroll.md | 4 +- 3 files changed, 342 insertions(+), 33 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt index f4113627f..9ee0fb237 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt @@ -19,6 +19,7 @@ package com.tencent.kuikly.compose.material3 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -76,15 +77,62 @@ private fun Modifier.offsetWithParentAdjustment( } } +internal data class PullToRefreshRuntimeConfig( + val holdRefreshInset: Boolean, + val refreshThresholdPx: Float, + val refreshThresholdLogical: Float +) + +internal data class PullToRefreshSnapshot( + val contentOffset: Int, + val isAtTop: Boolean, + val isDragging: Boolean, + val isRefreshing: Boolean, + val holdRefreshInset: Boolean, + val refreshThresholdPx: Float, + val refreshThresholdLogical: Float +) { + val pullDistance: Float + get() = if (contentOffset < 0) abs(contentOffset.toFloat()) else 0f + + val progress: Float + get() = (pullDistance / refreshThresholdPx).coerceIn(0f, 1f) + + val isThresholdReached: Boolean + get() = pullDistance >= refreshThresholdPx + + val endDragInset: Float + get() = pullRefreshEndDragInset( + holdRefreshInset = holdRefreshInset, + refreshThreshold = refreshThresholdLogical + ) +} + /** - * Quadruple data class for monitoring multiple states in snapshotFlow + * Keeps the long-lived scroll collector connected to configuration updated by recomposition. + * The provider instance stays attached to the same scroll state while [configState] changes. */ -private data class Quad( - val first: A, - val second: B, - val third: C, - val fourth: D -) +internal class PullToRefreshSnapshotProvider( + private val configState: State +) { + fun snapshot( + contentOffset: Int, + isAtTop: Boolean, + isDragging: Boolean, + isRefreshing: Boolean + ): PullToRefreshSnapshot { + val config = configState.value + return PullToRefreshSnapshot( + contentOffset = contentOffset, + isAtTop = isAtTop, + isDragging = isDragging, + isRefreshing = isRefreshing, + holdRefreshInset = config.holdRefreshInset, + refreshThresholdPx = config.refreshThresholdPx, + refreshThresholdLogical = config.refreshThresholdLogical + ) + } +} /** * Creates a [PullToRefreshState] that is remembered across compositions. @@ -178,6 +226,8 @@ class PullToRefreshState( * @param holdRefreshInset Whether the list should keep [refreshThreshold] top inset while * [state] is refreshing. Disable this when refresh progress is rendered outside the list and * existing content must return to its resting position immediately after the pull is released. + * Runtime changes to this value and [refreshThreshold] take effect without replacing + * [scrollState]. * @param content Custom refresh indicator content */ fun LazyListScope.pullToRefreshItem( @@ -238,6 +288,16 @@ internal fun PullToRefreshItem( val refreshThresholdPx = with(density) { refreshThreshold.toPx() } val refreshThresholdLogical = refreshThresholdPx / density.density val updatedOnRefresh by rememberUpdatedState(onRefresh) + val updatedRuntimeConfig = rememberUpdatedState( + PullToRefreshRuntimeConfig( + holdRefreshInset = holdRefreshInset, + refreshThresholdPx = refreshThresholdPx, + refreshThresholdLogical = refreshThresholdLogical + ) + ) + val snapshotProvider = remember(scrollState) { + PullToRefreshSnapshotProvider(updatedRuntimeConfig) + } scrollState.kuiklyInfo.pullToRefreshTopInsetPx = with(density) { topInset.roundToPx() } @@ -248,10 +308,20 @@ internal fun PullToRefreshItem( val isAtTop = scrollState.isAtTop() val contentOffset = if (isAtTop) kuiklyInfo.contentOffset else 0 val isDragging = scrollState.kuiklyInfo.isDragging - Quad(contentOffset, isAtTop, isDragging, state.isRefreshing) + snapshotProvider.snapshot( + contentOffset = contentOffset, + isAtTop = isAtTop, + isDragging = isDragging, + isRefreshing = state.isRefreshing + ) } .distinctUntilChanged() - .collectLatest { (contentOffset, isAtTop, isDragging, _) -> + .collectLatest { snapshot -> + val contentOffset = snapshot.contentOffset + val isAtTop = snapshot.isAtTop + val isDragging = snapshot.isDragging + val currentHoldRefreshInset = snapshot.holdRefreshInset + val currentRefreshThresholdPx = snapshot.refreshThresholdPx val previousPullState = state.pullState if (!isAtTop) { // Reset state when not at top @@ -273,8 +343,8 @@ internal fun PullToRefreshItem( // Handle pull logic when at top val scrollView = scrollState.kuiklyInfo.scrollView - val pullDistance = if (contentOffset < 0) abs(contentOffset.toFloat()) else 0f - val progress = (pullDistance / refreshThresholdPx).coerceIn(0f, 1f) + val pullDistance = snapshot.pullDistance + val progress = snapshot.progress state.updateProgress(progress) @@ -290,44 +360,50 @@ internal fun PullToRefreshItem( } } PullState.IDLE -> { - if (isDragging && pullDistance >= refreshThresholdPx) { + val pullStarted = state.startPullToRefresh( + snapshot = snapshot, + setEndDragInset = { inset -> + scrollView?.setContentInsetWhenEndDrag(top = inset) + } + ) + if (pullStarted) { pullToRefreshLog { "IDLE -> PULLING: offset=$contentOffset pullDistance=$pullDistance " + - "progress=$progress thresholdPx=$refreshThresholdPx" + "progress=$progress thresholdPx=$currentRefreshThresholdPx" } - state.updatePullState(PullState.PULLING) - scrollView?.setContentInsetWhenEndDrag( - top = pullRefreshEndDragInset( - holdRefreshInset = holdRefreshInset, - refreshThreshold = refreshThresholdLogical - ) - ) } } PullState.PULLING -> { if (isDragging) { - if (pullDistance < refreshThresholdPx) { + if (!snapshot.isThresholdReached) { pullToRefreshLog { "PULLING -> IDLE (drag, below threshold): offset=$contentOffset " + "pullDistance=$pullDistance progress=$progress" } state.updatePullState(PullState.IDLE) scrollView?.setContentInsetWhenEndDrag(top = 0f) + } else { + scrollView?.setContentInsetWhenEndDrag( + top = snapshot.endDragInset + ) } } else { // Released while pulling, start refresh - val releasedState = pullStateAfterRefreshRelease(holdRefreshInset) + val releasedState = pullStateAfterRefreshRelease(currentHoldRefreshInset) pullToRefreshLog { "PULLING -> $releasedState (release): offset=$contentOffset " + - "pullDistance=$pullDistance holdRefreshInset=$holdRefreshInset" - } - state.updatePullState(releasedState) - if (!holdRefreshInset) { - state.updateProgress(0f) - scrollView?.setContentInsetWhenEndDrag(top = 0f) - scrollView?.setContentInset(top = 0f, animated = false) + "pullDistance=$pullDistance holdRefreshInset=$currentHoldRefreshInset" } - updatedOnRefresh() + state.releasePullToRefresh( + snapshot = snapshot, + clearEndDragInset = { + scrollView?.setContentInsetWhenEndDrag(top = 0f) + }, + clearCurrentInset = { + scrollView?.setContentInset(top = 0f, animated = false) + }, + onRefresh = updatedOnRefresh + ) } } } @@ -341,7 +417,7 @@ internal fun PullToRefreshItem( } // Handle inset changes based on pull state - LaunchedEffect(state.pullState, holdRefreshInset) { + LaunchedEffect(state.pullState, holdRefreshInset, refreshThresholdLogical) { val scrollView = scrollState.kuiklyInfo.scrollView val isDragging = scrollView?.isDragging == true when (state.pullState) { @@ -421,6 +497,37 @@ internal fun pullRefreshEndDragInset( ): Float = if (holdRefreshInset) refreshThreshold.coerceAtLeast(0f) else 0f +internal fun PullToRefreshState.startPullToRefresh( + snapshot: PullToRefreshSnapshot, + setEndDragInset: (Float) -> Unit +): Boolean { + if (pullState != PullState.IDLE || !snapshot.isDragging || !snapshot.isThresholdReached) { + return false + } + updatePullState(PullState.PULLING) + setEndDragInset(snapshot.endDragInset) + return true +} + +internal fun PullToRefreshState.releasePullToRefresh( + snapshot: PullToRefreshSnapshot, + clearEndDragInset: () -> Unit, + clearCurrentInset: () -> Unit, + onRefresh: () -> Unit +): Boolean { + if (pullState != PullState.PULLING) { + return false + } + updatePullState(pullStateAfterRefreshRelease(snapshot.holdRefreshInset)) + if (!snapshot.holdRefreshInset) { + updateProgress(0f) + clearEndDragInset() + clearCurrentInset() + } + onRefresh() + return true +} + /** * Default refresh indicator */ diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt index 51bd76704..b5d1a74d0 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt @@ -15,8 +15,21 @@ package com.tencent.kuikly.compose.material3 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class PullToRefreshStateTest { @Test @@ -56,4 +69,193 @@ class PullToRefreshStateTest { ) ) } + + @Test + fun noHoldReleaseClearsInsetsBeforeExactlyOnceRefreshCallback() { + val state = PullToRefreshState(isRefreshing = false).apply { + updatePullState(PullState.PULLING) + updateProgress(1f) + } + val events = mutableListOf() + + repeat(2) { + state.releasePullToRefresh( + snapshot = snapshot(holdRefreshInset = false), + clearEndDragInset = { events += "end-drag-inset=0" }, + clearCurrentInset = { events += "current-inset=0" }, + onRefresh = { events += "refresh" } + ) + } + + assertEquals(PullState.IDLE, state.pullState) + assertEquals(0f, state.pullProgress) + assertEquals( + listOf("end-drag-inset=0", "current-inset=0", "refresh"), + events + ) + } + + @Test + fun heldReleaseKeepsLegacyStateAndDispatchesExactlyOnce() { + val state = PullToRefreshState(isRefreshing = false).apply { + updatePullState(PullState.PULLING) + updateProgress(1f) + } + val events = mutableListOf() + + repeat(2) { + state.releasePullToRefresh( + snapshot = snapshot(holdRefreshInset = true), + clearEndDragInset = { events += "unexpected-end-drag-clear" }, + clearCurrentInset = { events += "unexpected-current-clear" }, + onRefresh = { events += "refresh" } + ) + } + + assertEquals(PullState.REFRESHING, state.pullState) + assertEquals(1f, state.pullProgress) + assertEquals(listOf("refresh"), events) + } + + @Test + fun sameCollectorAppliesTrueToFalseAndUpdatedThresholdOnNextGesture() { + val snapshots = collectRuntimeSnapshots( + initialConfig = PullToRefreshRuntimeConfig( + holdRefreshInset = true, + refreshThresholdPx = 80f, + refreshThresholdLogical = 80f + ), + updatedConfig = PullToRefreshRuntimeConfig( + holdRefreshInset = false, + refreshThresholdPx = 120f, + refreshThresholdLogical = 120f + ) + ) + assertTrue(snapshots[0].isThresholdReached) + assertEquals(80f, snapshots[0].endDragInset) + assertFalse(snapshots[1].isThresholdReached) + assertEquals(120f, snapshots[1].refreshThresholdPx) + assertEquals(0f, snapshots[1].endDragInset) + assertTrue(snapshots[2].isThresholdReached) + assertEquals(0f, snapshots[2].endDragInset) + + val state = PullToRefreshState(isRefreshing = false) + val events = mutableListOf() + val plannedInsets = mutableListOf() + + assertTrue(state.startPullToRefresh(snapshots[2], plannedInsets::add)) + repeat(2) { + state.releasePullToRefresh( + snapshot = snapshots[2], + clearEndDragInset = { events += "end-drag-inset=0" }, + clearCurrentInset = { events += "current-inset=0" }, + onRefresh = { events += "no-hold-refresh" } + ) + } + + assertEquals(PullState.IDLE, state.pullState) + assertEquals(listOf(0f), plannedInsets) + assertEquals( + listOf( + "end-drag-inset=0", + "current-inset=0", + "no-hold-refresh" + ), + events + ) + } + + @Test + fun sameCollectorAppliesFalseToTrueAndUpdatedThresholdOnNextGesture() { + val snapshots = collectRuntimeSnapshots( + initialConfig = PullToRefreshRuntimeConfig( + holdRefreshInset = false, + refreshThresholdPx = 80f, + refreshThresholdLogical = 80f + ), + updatedConfig = PullToRefreshRuntimeConfig( + holdRefreshInset = true, + refreshThresholdPx = 120f, + refreshThresholdLogical = 120f + ) + ) + assertTrue(snapshots[0].isThresholdReached) + assertEquals(0f, snapshots[0].endDragInset) + assertFalse(snapshots[1].isThresholdReached) + assertEquals(120f, snapshots[1].refreshThresholdPx) + assertEquals(120f, snapshots[1].endDragInset) + assertTrue(snapshots[2].isThresholdReached) + assertEquals(120f, snapshots[2].endDragInset) + + val state = PullToRefreshState(isRefreshing = false) + val events = mutableListOf() + val plannedInsets = mutableListOf() + + assertTrue(state.startPullToRefresh(snapshots[2], plannedInsets::add)) + repeat(2) { + state.releasePullToRefresh( + snapshot = snapshots[2], + clearEndDragInset = { events += "unexpected-held-end-drag-clear" }, + clearCurrentInset = { events += "unexpected-held-current-clear" }, + onRefresh = { events += "held-refresh" } + ) + } + + assertEquals(PullState.REFRESHING, state.pullState) + assertEquals(listOf(120f), plannedInsets) + assertEquals(listOf("held-refresh"), events) + } + + private fun collectRuntimeSnapshots( + initialConfig: PullToRefreshRuntimeConfig, + updatedConfig: PullToRefreshRuntimeConfig + ): List = runBlocking { + val configState = mutableStateOf(initialConfig) + val contentOffset = mutableStateOf(-100) + val snapshotProvider = PullToRefreshSnapshotProvider(configState) + val firstSnapshot = CompletableDeferred() + val updatedConfigSnapshot = CompletableDeferred() + var emissionCount = 0 + val snapshots = async(start = CoroutineStart.UNDISPATCHED) { + snapshotFlow { + snapshotProvider.snapshot( + contentOffset = contentOffset.value, + isAtTop = true, + isDragging = true, + isRefreshing = false + ) + } + .onEach { + emissionCount += 1 + when (emissionCount) { + 1 -> firstSnapshot.complete(Unit) + 2 -> updatedConfigSnapshot.complete(Unit) + } + } + .take(3) + .toList() + } + + withTimeout(5_000) { firstSnapshot.await() } + configState.value = updatedConfig + Snapshot.sendApplyNotifications() + withTimeout(5_000) { updatedConfigSnapshot.await() } + contentOffset.value = -130 + Snapshot.sendApplyNotifications() + withTimeout(5_000) { snapshots.await() } + } + + private fun snapshot( + holdRefreshInset: Boolean, + refreshThreshold: Float = 80f, + contentOffset: Int = -100 + ): PullToRefreshSnapshot = PullToRefreshSnapshot( + contentOffset = contentOffset, + isAtTop = true, + isDragging = false, + isRefreshing = false, + holdRefreshInset = holdRefreshInset, + refreshThresholdPx = refreshThreshold, + refreshThresholdLogical = refreshThreshold + ) } diff --git a/docs/Compose/list-and-scroll.md b/docs/Compose/list-and-scroll.md index 4fb477c5b..e8d1c298e 100644 --- a/docs/Compose/list-and-scroll.md +++ b/docs/Compose/list-and-scroll.md @@ -173,8 +173,8 @@ fun PullToRefreshList(data: List) { | 参数 | 默认值 | 说明 | |------|--------|------| | `topInset` | `0.dp` | overlay HeaderBar 等场景下,PTR item 顶部的额外留白。传 **header 展开时的最大高度**,不是动画中的实时高度。框架会在 PTR item 内部自动应用等效 `padding(top)`,**请勿**再在 `modifier` 上重复设置 `padding(top = ...)`。未设置时行为与原来一致。 | -| `refreshThreshold` | `80.dp` | 触发刷新的下拉距离 | -| `holdRefreshInset` | `true` | 刷新期间是否持续保留 `refreshThreshold` 高度的顶部 content inset。若刷新进度由列表外的固定 Header 展示,并要求松手后列表内容立即回到原位,可设为 `false`;拖动阶段的 progress 与阈值触发语义不变。 | +| `refreshThreshold` | `80.dp` | 触发刷新的下拉距离;同一个 `scrollState` 重组时更新即可生效,无需重建列表状态。 | +| `holdRefreshInset` | `true` | 刷新期间是否持续保留 `refreshThreshold` 高度的顶部 content inset。若刷新进度由列表外的固定 Header 展示,并要求松手后列表内容立即回到原位,可设为 `false`;拖动阶段的 progress 与阈值触发语义不变。同一个 `scrollState` 下运行时切换也会作用于当前/下一次 pull。 | #### overlay HeaderBar(`topInset`) From 14b3a142e78be30f8c877191bb04de14cfd4a90f Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Wed, 15 Jul 2026 22:50:13 +0800 Subject: [PATCH 117/187] fix(compose): defer lazy offset alignment during scroll (#47) Signed-off-by: Codex-KMP-Developer --- .../compose/gestures/KuiklyScrollInfo.kt | 54 ++- .../compose/layout/SubcomposeLayoutEx.kt | 19 +- .../compose/scroller/ContentSizeExtensions.kt | 139 ++++++-- .../scroller/ScrollableStateExtensions.kt | 11 +- .../compose/ui/layout/SubcomposeLayout.kt | 3 + .../scroller/ContentSizeExtensionsTest.kt | 311 ++++++++++++++++++ 6 files changed, 498 insertions(+), 39 deletions(-) create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensionsTest.kt diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 7a36307b8..4ad914d09 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -31,6 +31,51 @@ import com.tencent.kuikly.core.views.ScrollerView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +internal class DeferredScrollOffsetAlignmentCoordinator( + private val pendingAlignment: () -> T?, + private val updatePendingAlignment: (T?) -> Unit +) { + private var generation = 0L + + fun replacePendingAlignment( + cancelPendingAlignment: (T) -> Unit, + launchAlignment: (DeferredScrollOffsetAlignmentRequest) -> T? + ) { + val request = DeferredScrollOffsetAlignmentRequest(++generation) + pendingAlignment()?.let(cancelPendingAlignment) + updatePendingAlignment(launchAlignment(request)) + } + + fun isCurrent(request: DeferredScrollOffsetAlignmentRequest): Boolean { + return request.generation == generation + } + + fun cancelAndInvalidate(cancelPendingAlignment: (T) -> Unit) { + generation += 1 + pendingAlignment()?.let(cancelPendingAlignment) + updatePendingAlignment(null) + } + + fun retryAfterScrollEnd(scheduleAlignment: () -> Unit) { + scheduleAlignment() + } +} + +internal fun invalidateDeferredScrollOffsetAlignmentOwnersOnReuse( + oldCoordinator: DeferredScrollOffsetAlignmentCoordinator?, + newCoordinator: DeferredScrollOffsetAlignmentCoordinator, + cancelPendingAlignment: (T) -> Unit +) { + oldCoordinator?.cancelAndInvalidate(cancelPendingAlignment) + if (newCoordinator !== oldCoordinator) { + newCoordinator.cancelAndInvalidate(cancelPendingAlignment) + } +} + +internal class DeferredScrollOffsetAlignmentRequest internal constructor( + internal val generation: Long +) + /** * Scroll information management class, responsible for handling scroll-related state and calculations */ @@ -121,6 +166,12 @@ class KuiklyScrollInfo { */ internal var appleScrollViewOffsetJob: Job? = null + internal val deferredScrollOffsetAlignmentCoordinator = + DeferredScrollOffsetAlignmentCoordinator( + pendingAlignment = { appleScrollViewOffsetJob }, + updatePendingAlignment = { appleScrollViewOffsetJob = it } + ) + /** * Coroutine scope */ @@ -215,8 +266,7 @@ class KuiklyScrollInfo { */ fun resetForNewScrollView() { // Cancel and clear any pending tasks - appleScrollViewOffsetJob?.cancel() - appleScrollViewOffsetJob = null + deferredScrollOffsetAlignmentCoordinator.cancelAndInvalidate { it.cancel() } // Reset basic offset and scroll state ignoreScrollOffset = null diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt index a8101d428..8c638e760 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/layout/SubcomposeLayoutEx.kt @@ -22,6 +22,7 @@ import com.tencent.kuikly.compose.foundation.lazy.grid.LazyGridMeasureResult import com.tencent.kuikly.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridMeasureResult import com.tencent.kuikly.compose.foundation.pager.PagerMeasureResult import com.tencent.kuikly.compose.gestures.KuiklyScrollInfo +import com.tencent.kuikly.compose.gestures.invalidateDeferredScrollOffsetAlignmentOwnersOnReuse import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.compose.ui.layout.LayoutNodeSubcompositionsState import com.tencent.kuikly.compose.ui.layout.MeasureResult @@ -121,6 +122,20 @@ internal fun transferScrollToTopCallback(old: KuiklyScrollInfo?, new: KuiklyScro } } +internal fun invalidateDeferredScrollOffsetAlignmentOnReuse( + old: KuiklyScrollInfo?, + new: KuiklyScrollInfo +) { + invalidateDeferredScrollOffsetAlignmentOwnersOnReuse( + oldCoordinator = old?.deferredScrollOffsetAlignmentCoordinator, + newCoordinator = new.deferredScrollOffsetAlignmentCoordinator, + cancelPendingAlignment = { it.cancel() } + ) + if (old != null && old !== new) { + old.scrollView = null + } +} + /** * Restore ScrollerView state during the update block (both first creation and reuse). * @@ -147,8 +162,6 @@ internal fun restoreScrollerViewOnReuse( sv.prepareForComposeReuse() kuiklyInfo.ignoreScrollOffset = null - kuiklyInfo.appleScrollViewOffsetJob?.cancel() - kuiklyInfo.appleScrollViewOffsetJob = null kuiklyInfo.realContentSize = null // Restore contentSize first (UIKit clamps contentOffset to contentSize bounds) @@ -171,4 +184,4 @@ internal fun restoreScrollerViewOnReuse( ) } sv.setContentOffset(restoreOffsetX, restoreOffsetY, animated = false) -} \ No newline at end of file +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 14b81d5ca..bbd286de0 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt @@ -25,6 +25,7 @@ import com.tencent.kuikly.compose.foundation.lazy.staggeredgrid.LazyStaggeredGri import com.tencent.kuikly.compose.foundation.drawer.DrawerInternalPagerState import com.tencent.kuikly.compose.foundation.pager.PagerState import com.tencent.kuikly.compose.foundation.pager.ScrollViewOffsetAlignmentCancellation +import com.tencent.kuikly.compose.gestures.DeferredScrollOffsetAlignmentCoordinator import com.tencent.kuikly.compose.scroller.ScrollableStateConstants.DEFAULT_CONTENT_SIZE import com.tencent.kuikly.compose.ui.unit.Dp import com.tencent.kuikly.compose.ui.unit.LayoutDirection @@ -309,42 +310,116 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea internal fun ScrollableState.tryExpandStartSizeNoScroll(forceExpand: Boolean = false) { if (this is PagerState || this is DrawerInternalPagerState) return + val scrollInProgress = { this@tryExpandStartSizeNoScroll.isScrollInProgress } kuiklyInfo.run { - appleScrollViewOffsetJob?.cancel(ScrollViewOffsetAlignmentCancellation) - appleScrollViewOffsetJob = scope?.launch { - delay(150) - val minDelta = (DEFAULT_CONTENT_SIZE * getDensity()).toInt() - val epsilon = 0.5 * getDensity() // 使用 0.5dp 作为误差值 - val reachBtm = contentOffset + viewportSize - currentContentSize >= -epsilon - - if (contentOffset <= 0 && !isComposeAtTopForScrollSync() && (forceExpand || scrollView?.isDragging != true)) { - // 整体把offset 加一下 - var delta = calculateBackExpandSize(contentOffset) - delta = max(delta ?: minDelta, minDelta) - val maxDelta = currentContentSize - viewportSize - contentOffset - if (delta > maxDelta) { - // 不够直接扩容offset,先扩容contentSize - currentContentSize += (delta - maxDelta + minDelta) + scheduleDeferredScrollOffsetAlignment( + coordinator = deferredScrollOffsetAlignmentCoordinator, + forceExpand = forceExpand, + isScrollInProgress = scrollInProgress, + cancelPendingAlignment = { it.cancel(ScrollViewOffsetAlignmentCancellation) }, + launchAlignment = { alignment -> scope?.launch { alignment() } }, + awaitAlignmentWindow = { delay(150) }, + applyAlignment = applyAlignment@{ isCurrent -> + val minDelta = (DEFAULT_CONTENT_SIZE * getDensity()).toInt() + val epsilon = 0.5 * getDensity() // 使用 0.5dp 作为误差值 + val reachBtm = contentOffset + viewportSize - currentContentSize >= -epsilon + + if (contentOffset <= 0 && !isComposeAtTopForScrollSync() && (forceExpand || scrollView?.isDragging != true)) { + // 整体把offset 加一下 + var delta = calculateBackExpandSize(contentOffset) + delta = max(delta ?: minDelta, minDelta) + val maxDelta = currentContentSize - viewportSize - contentOffset + if (delta > maxDelta) { + // 不够直接扩容offset,先扩容contentSize + currentContentSize += (delta - maxDelta + minDelta) + updateContentSizeToRender() + } + if (pageData?.isOhOs == true) { + if (!shouldApplyDeferredScrollOffsetAlignmentAfterOhosRefresh( + forceExpand = forceExpand, + isScrollInProgress = scrollInProgress, + isCurrent = isCurrent, + awaitRefreshWindow = { + // 鸿蒙扩容后不会立刻刷新,也没有刷新 API,华为建议添加 delay。 + delay(25) + } + ) + ) { + return@applyAlignment + } + } + applyScrollViewOffsetDelta(delta) + offsetDirty = true + } else if (contentOffset > 0 && isComposeAtTopForScrollSync()) { + // compose 到顶了,但是scrollview没到顶 + applyScrollViewOffsetDelta(-contentOffset) + offsetDirty = false + } else if (isAtTop() && realContentSize == null && lastItemVisible() && scrollView?.isDragging != true) { + // 更新当前的contentSize大小 + currentContentSize = calculateContentSize() + updateContentSizeToRender() + } else if (canScrollForward && reachBtm) { + // 底部无法滑动了,扩容 + currentContentSize += minDelta updateContentSizeToRender() } - if (pageData?.isOhOs == true) { - delay(25) // 鸿蒙扩容后,不会立刻刷新,也没有刷新api,华为建议添加一个delay来处理 + } + ) + } +} + +internal fun scheduleDeferredScrollOffsetAlignment( + coordinator: DeferredScrollOffsetAlignmentCoordinator, + forceExpand: Boolean, + isScrollInProgress: () -> Boolean, + cancelPendingAlignment: (T) -> Unit, + launchAlignment: (suspend () -> Unit) -> T?, + awaitAlignmentWindow: suspend () -> Unit, + applyAlignment: suspend (isCurrent: () -> Boolean) -> Unit +) { + coordinator.replacePendingAlignment( + cancelPendingAlignment = cancelPendingAlignment, + launchAlignment = { request -> + launchAlignment { + if ( + shouldApplyDeferredScrollOffsetAlignmentAfterWindow( + forceExpand = forceExpand, + isScrollInProgress = isScrollInProgress, + awaitAlignmentWindow = awaitAlignmentWindow + ) && coordinator.isCurrent(request) + ) { + applyAlignment { coordinator.isCurrent(request) } } - applyScrollViewOffsetDelta(delta) - offsetDirty = true - } else if (contentOffset > 0 && isComposeAtTopForScrollSync()) { - // compose 到顶了,但是scrollview没到顶 - applyScrollViewOffsetDelta(-contentOffset) - offsetDirty = false - } else if (isAtTop() && realContentSize == null && lastItemVisible() && scrollView?.isDragging != true) { - // 更新当前的contentSize大小 - currentContentSize = calculateContentSize() - updateContentSizeToRender() - } else if (canScrollForward && reachBtm) { - // 底部无法滑动了,扩容 - currentContentSize += minDelta - updateContentSizeToRender() } } - } + ) +} + +internal suspend fun shouldApplyDeferredScrollOffsetAlignmentAfterWindow( + forceExpand: Boolean, + isScrollInProgress: () -> Boolean, + awaitAlignmentWindow: suspend () -> Unit +): Boolean { + awaitAlignmentWindow() + // Native dragging is already false during settling, while Compose still owns an active + // scroll. Scroll end clears that state and schedules alignment again. + return shouldApplyDeferredScrollOffsetAlignment(isScrollInProgress(), forceExpand) +} + +internal fun shouldApplyDeferredScrollOffsetAlignment( + isScrollInProgress: Boolean, + forceExpand: Boolean +): Boolean = forceExpand || !isScrollInProgress + +internal suspend fun shouldApplyDeferredScrollOffsetAlignmentAfterOhosRefresh( + forceExpand: Boolean, + isScrollInProgress: () -> Boolean, + isCurrent: () -> Boolean, + awaitRefreshWindow: suspend () -> Unit +): Boolean { + awaitRefreshWindow() + return isCurrent() && shouldApplyDeferredScrollOffsetAlignment( + isScrollInProgress = isScrollInProgress(), + forceExpand = forceExpand + ) } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt index 950a6e040..8555468c0 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ScrollableStateExtensions.kt @@ -62,7 +62,14 @@ internal fun ScrollableState.kuiklyOnScroll(delta: Float): Float = when (this) { /** * Handle scroll end events */ -internal fun ScrollableState.kuiklyOnScrollEnd(params: ScrollParams) { +internal fun ScrollableState.kuiklyOnScrollEnd( + params: ScrollParams, + retryDeferredAlignment: ScrollableState.() -> Unit = { + kuiklyInfo.deferredScrollOffsetAlignmentCoordinator.retryAfterScrollEnd { + tryExpandStartSizeNoScroll() + } + } +) { when (this) { is LazyListState -> scrollableState.kuiklyOnScrollEnd(params) is PagerState -> { @@ -81,7 +88,7 @@ internal fun ScrollableState.kuiklyOnScrollEnd(params: ScrollParams) { } // Pager uses a different scroll-end sync path; skip lazy scroll expansion here. if (this !is PagerState && this !is DrawerInternalPagerState && this !is KuiklyScrollableState) { - tryExpandStartSizeNoScroll() + retryDeferredAlignment() } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 56ebc1c53..29dd9504e 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -71,6 +71,7 @@ import com.tencent.kuikly.compose.views.VirtualNodeView import com.tencent.kuikly.compose.layout.bindKuiklyInfo import com.tencent.kuikly.compose.layout.checkOffScreenNode import com.tencent.kuikly.compose.layout.hideOffsetScreenView +import com.tencent.kuikly.compose.layout.invalidateDeferredScrollOffsetAlignmentOnReuse import com.tencent.kuikly.compose.layout.restoreScrollerViewOnReuse import com.tencent.kuikly.compose.layout.transferScrollToTopCallback import com.tencent.kuikly.compose.scroller.handleScrollToTopCallback @@ -433,6 +434,8 @@ fun SubcomposeLayout( scrollViewRef = sv val oldKuiklyInfo = sv.extProps[KuiklyInfoKey] as? KuiklyScrollInfo + val newKuiklyInfo = scrollableState.kuiklyInfo + invalidateDeferredScrollOffsetAlignmentOnReuse(oldKuiklyInfo, newKuiklyInfo) val kuiklyInfo = bindKuiklyInfo(sv, scrollableState, orientation) transferScrollToTopCallback(oldKuiklyInfo, kuiklyInfo) restoreScrollerViewOnReuse(sv, kuiklyInfo, isPagerView, orientation, oldKuiklyInfo?.contentOffset) diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensionsTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensionsTest.kt new file mode 100644 index 000000000..782d59191 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensionsTest.kt @@ -0,0 +1,311 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2026 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.scroller + +import com.tencent.kuikly.compose.foundation.ScrollState +import com.tencent.kuikly.compose.gestures.DeferredScrollOffsetAlignmentCoordinator +import com.tencent.kuikly.compose.gestures.invalidateDeferredScrollOffsetAlignmentOwnersOnReuse +import com.tencent.kuikly.core.views.ScrollParams +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ContentSizeExtensionsTest { + + @Test + fun deferredAlignmentSkipsActiveScrollUnlessForced() { + assertFalse( + shouldApplyDeferredScrollOffsetAlignment( + isScrollInProgress = true, + forceExpand = false + ) + ) + assertTrue( + shouldApplyDeferredScrollOffsetAlignment( + isScrollInProgress = false, + forceExpand = false + ) + ) + assertTrue( + shouldApplyDeferredScrollOffsetAlignment( + isScrollInProgress = true, + forceExpand = true + ) + ) + } + + @Test + fun deferredAlignmentReadsLatestStateAfterWindow() { + val harness = DeferredAlignmentHarness() + + harness.schedule(duringWait = { harness.isScrollInProgress = true }).complete() + + assertEquals(0, harness.appliedActions) + } + + @Test + fun replacementCancelsPendingAlignmentBeforeLatestAction() { + val harness = DeferredAlignmentHarness() + + val first = harness.schedule() + val second = harness.schedule() + first.complete() + second.complete() + + assertEquals(1, harness.appliedActions) + assertEquals(1, harness.cancelledAlignments) + } + + @Test + fun staleCompletionCannotApplyEvenWhenCancellationIsNotObserved() { + val harness = DeferredAlignmentHarness() + + val first = harness.schedule() + val second = harness.schedule() + first.completeIgnoringCancellation() + second.complete() + + assertEquals(1, harness.appliedActions) + } + + @Test + fun invalidationRejectsNonCooperativeLateCompletionWithoutReplacement() { + val harness = DeferredAlignmentHarness() + + val pending = harness.schedule() + harness.cancelAndInvalidate() + pending.completeIgnoringCancellation() + + assertEquals(0, harness.appliedActions) + assertEquals(1, harness.cancelledAlignments) + } + + @Test + fun reuseInvalidatesOldOwnerBeforeNonCooperativeLateCompletion() { + val oldOwner = DeferredAlignmentHarness() + val newOwner = DeferredAlignmentHarness() + val oldPending = oldOwner.schedule() + var cancellations = 0 + + invalidateDeferredScrollOffsetAlignmentOwnersOnReuse( + oldCoordinator = oldOwner.coordinator, + newCoordinator = newOwner.coordinator, + cancelPendingAlignment = { + cancellations += 1 + it.cancel() + } + ) + oldPending.completeIgnoringCancellation() + + assertEquals(0, oldOwner.appliedActions) + assertEquals(0, newOwner.appliedActions) + assertEquals(1, cancellations) + } + + @Test + fun reuseInvalidatesSameOwnerOnlyOnce() { + val owner = DeferredAlignmentHarness() + owner.schedule() + var cancellations = 0 + + invalidateDeferredScrollOffsetAlignmentOwnersOnReuse( + oldCoordinator = owner.coordinator, + newCoordinator = owner.coordinator, + cancelPendingAlignment = { + cancellations += 1 + it.cancel() + } + ) + + assertEquals(1, cancellations) + } + + @Test + fun ohosRefreshWindowRejectsFreshGesture() { + var isScrollInProgress = false + + val shouldApply = runImmediateSuspend { + shouldApplyDeferredScrollOffsetAlignmentAfterOhosRefresh( + forceExpand = false, + isScrollInProgress = { isScrollInProgress }, + isCurrent = { true }, + awaitRefreshWindow = { isScrollInProgress = true } + ) + } + + assertFalse(shouldApply) + } + + @Test + fun ohosRefreshWindowAllowsForcedAlignmentDuringFreshGesture() { + var isScrollInProgress = false + + val shouldApply = runImmediateSuspend { + shouldApplyDeferredScrollOffsetAlignmentAfterOhosRefresh( + forceExpand = true, + isScrollInProgress = { isScrollInProgress }, + isCurrent = { true }, + awaitRefreshWindow = { isScrollInProgress = true } + ) + } + + assertTrue(shouldApply) + } + + @Test + fun ohosRefreshWindowRejectsInvalidatedRequest() { + var isCurrent = true + + val shouldApply = runImmediateSuspend { + shouldApplyDeferredScrollOffsetAlignmentAfterOhosRefresh( + forceExpand = true, + isScrollInProgress = { false }, + isCurrent = { isCurrent }, + awaitRefreshWindow = { isCurrent = false } + ) + } + + assertFalse(shouldApply) + } + + @Test + fun scrollEndRetriesSkippedAlignmentExactlyOnceWhenIdle() { + val harness = DeferredAlignmentHarness(isScrollInProgress = true) + + harness.schedule().complete() + assertEquals(0, harness.appliedActions) + + harness.isScrollInProgress = false + harness.coordinator.retryAfterScrollEnd { + harness.schedule().complete() + } + + assertEquals(1, harness.appliedActions) + } + + @Test + fun forcedReplacementAppliesOnceDuringActiveScroll() { + val harness = DeferredAlignmentHarness(isScrollInProgress = true) + + val first = harness.schedule(forceExpand = true) + val second = harness.schedule(forceExpand = true) + first.complete() + second.complete() + + assertEquals(1, harness.appliedActions) + } + + @Test + fun scrollEndInvokesProductionRetryExactlyOnce() { + val state = ScrollState(0) + var retries = 0 + + state.kuiklyOnScrollEnd( + params = ScrollParams( + offsetX = 0f, + offsetY = 0f, + contentWidth = 100f, + contentHeight = 100f, + viewWidth = 100f, + viewHeight = 100f, + isDragging = false + ), + retryDeferredAlignment = { retries += 1 } + ) + + assertEquals(1, retries) + } + + private class DeferredAlignmentHarness( + var isScrollInProgress: Boolean = false + ) { + private var pendingAlignment: PendingAlignment? = null + var appliedActions: Int = 0 + private set + var cancelledAlignments: Int = 0 + private set + + val coordinator = DeferredScrollOffsetAlignmentCoordinator( + pendingAlignment = { pendingAlignment }, + updatePendingAlignment = { pendingAlignment = it } + ) + + fun schedule( + forceExpand: Boolean = false, + duringWait: () -> Unit = {} + ): PendingAlignment { + lateinit var scheduledAlignment: PendingAlignment + scheduleDeferredScrollOffsetAlignment( + coordinator = coordinator, + forceExpand = forceExpand, + isScrollInProgress = { isScrollInProgress }, + cancelPendingAlignment = { + cancelledAlignments += 1 + it.cancel() + }, + launchAlignment = { alignment -> + PendingAlignment { runImmediateSuspend(alignment) } + .also { scheduledAlignment = it } + }, + awaitAlignmentWindow = { duringWait() }, + applyAlignment = { appliedActions += 1 } + ) + return scheduledAlignment + } + + fun cancelAndInvalidate() { + coordinator.cancelAndInvalidate { + cancelledAlignments += 1 + it.cancel() + } + } + } + + private class PendingAlignment( + private val action: () -> Unit + ) { + private var isCancelled = false + + fun cancel() { + isCancelled = true + } + + fun complete() { + if (!isCancelled) action() + } + + fun completeIgnoringCancellation() { + action() + } + } + +} + +private fun runImmediateSuspend(block: suspend () -> T): T { + var outcome: Result? = null + block.startCoroutine(object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + outcome = result + } + }) + return checkNotNull(outcome).getOrThrow() +} From f31bf8207fe44da5a8beef3c3db3a32bc768da94 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 16 Jul 2026 01:47:37 +0800 Subject: [PATCH 118/187] fix(ohos): surface image adapter failures (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: KMP-专家 Co-authored-by: KMP-专家 --- .../cpp/libohos_render/api/include/Kuikly/Kuikly.h | 3 ++- .../expand/components/image/KRImageView.cpp | 14 ++++++++++++-- .../expand/components/image/KRImageView.h | 1 + 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/core-render-ohos/src/main/cpp/libohos_render/api/include/Kuikly/Kuikly.h b/core-render-ohos/src/main/cpp/libohos_render/api/include/Kuikly/Kuikly.h index 1f25f3543..00206a722 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/api/include/Kuikly/Kuikly.h +++ b/core-render-ohos/src/main/cpp/libohos_render/api/include/Kuikly/Kuikly.h @@ -212,7 +212,8 @@ typedef char *(*KRImageAdapter)(const char *imageSrc, ArkUI_DrawableDescriptor * * @param src src image组件设置的src属性 * @param image_descriptor 解码好的图片 * @param new_src 新的src地址,比如从原src映射到一个新的src路径 - * @discuss 当image_descriptor非空时,kuikly优先用image_descriptor,其次再使用new_src + * @discuss 当image_descriptor非空时,kuikly优先用image_descriptor;否则使用非空new_src。 + * image_descriptor和new_src都为空时,表示adapter已处理但加载失败,Kuikly会触发loadFailure。 */ typedef void (*KRSetImageCallback)(const void* context, const char *src, diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.cpp index 927e8af46..50147e3a0 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.cpp @@ -54,6 +54,7 @@ constexpr char kEventNameLoadSuccess[] = "loadSuccess"; constexpr char kEventNameLoadResolution[] = "loadResolution"; constexpr char kEventNameLoadFailure[] = "loadFailure"; constexpr char kEventNameLoadErrorCode[] = "errorCode"; +constexpr int32_t kAdapterImageLoadErrorCode = -1; constexpr char kParamKeyImageWidth[] = "imageWidth"; constexpr char kParamKeyImageHeight[] = "imageHeight"; constexpr char kPropNameMaskLinearGradient[] = "maskLinearGradient"; @@ -207,10 +208,10 @@ void KRImageView::AdapterSetImageCallback(const void* context, if (imageDescriptor) { kuikly::util::SetArkUIImageSrc(image_view->GetNode(), imageDescriptor); - } else if (new_src) { + } else if (new_src && new_src[0] != '\0') { image_view->LoadFromSrc(std::string(new_src)); } else { - KR_LOG_INFO << "Neither image descriptor nor new_src is returned"; + image_view->FireAdapterImageErrorEvent(); } } } @@ -468,6 +469,15 @@ void KRImageView::FireOnImageErrorEvent(ArkUI_NodeEvent *event) { } } +void KRImageView::FireAdapterImageErrorEvent() { + if (load_failure_callback_) { + KRRenderValueMap map; + map[kPropNameSrc] = NewKRRenderValue(image_src_); + map[kEventNameLoadErrorCode] = NewKRRenderValue(kAdapterImageLoadErrorCode); + load_failure_callback_(NewKRRenderValue(map)); + } +} + void KRImageView::FireOnImageCompleteEvent(ArkUI_NodeEvent *event) { if (!kuikly::util::IsImageLoadSuccessStatus(event)) { return; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.h index a4134a55a..af245e6fc 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/image/KRImageView.h @@ -59,6 +59,7 @@ class KRImageView : public IKRRenderViewExport { bool RegisterLoadFailureCallback(const KRRenderCallback &event_callback); void FireOnImageCompleteEvent(ArkUI_NodeEvent *event); void FireOnImageErrorEvent(ArkUI_NodeEvent *event); + void FireAdapterImageErrorEvent(); std::shared_ptr ToImageLoadOption(const std::string &src); void LoadFromSrc(const std::string image_src); void LoadFromNetwork(const std::shared_ptr image_option); From e2f6770381acdfbc6700b3ffeed705a85ff46927 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 16 Jul 2026 02:51:29 +0800 Subject: [PATCH 119/187] fix(compose): redraw reused native views (#49) Signed-off-by: Android-Developer-1 Co-authored-by: Android-Developer-1 --- .../com/tencent/kuikly/compose/ui/node/KNode.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt index a0b46c00b..f70e09f4f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt @@ -143,6 +143,15 @@ internal class KNode>( super.detach() } + override fun onReuse() { + super.onReuse() + // A reusable Compose slot keeps the same native view while its modifier chain can now + // represent semantically different content. Always redraw so reset() -> flush() clears + // render-only state (for example borderRadius and clipPath) left by the previous item, + // even when the replacement modifiers are structurally equal and emit no invalidation. + invalidateDraw() + } + override fun onRelease() { // Release child subcompositions before clearing the Kuikly view tree. Otherwise nested // applier removeAt calls may observe an already-cleared ViewContainer.children list. @@ -715,4 +724,4 @@ internal class KNode>( ) } } -} \ No newline at end of file +} From cc781232a64b85db4f6a87057448fa138df2d7a8 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 16 Jul 2026 11:05:22 +0800 Subject: [PATCH 120/187] fix(compose): propagate draw invalidation across reused dirty ancestors Propagate reuse-specific draw invalidation through consecutive dirty KNode ancestors so a clean reparented root is woken and retained native properties are flushed.\n\nValidation: exact hosted common metadata + Android JVM, 68 tests, 0 failures.\n\nSigned-off-by: Android-Developer-1 --- .github/workflows/compose-pr.yml | 78 ++++++++++++ .../tencent/kuikly/compose/ui/node/KNode.kt | 24 +++- .../ui/node/KNodeDrawInvalidationTest.kt | 114 ++++++++++++++++++ 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/compose-pr.yml create mode 100644 compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeDrawInvalidationTest.kt diff --git a/.github/workflows/compose-pr.yml b/.github/workflows/compose-pr.yml new file mode 100644 index 000000000..78222910a --- /dev/null +++ b/.github/workflows/compose-pr.yml @@ -0,0 +1,78 @@ +name: Compose PR + +on: + pull_request: + branches: + - staging2 + paths: + - "compose/**" + - "core/**" + - "buildSrc/**" + - "gradle/**" + - "build.gradle*" + - "settings.gradle*" + - "gradle.properties" + - ".github/workflows/compose-pr.yml" + +permissions: + contents: read + +jobs: + common-and-android-jvm: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out exact PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Verify exact PR head + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + echo "expected_head_sha=${EXPECTED_HEAD_SHA}" + echo "actual_head_sha=${actual_head_sha}" + test "${actual_head_sha}" = "${EXPECTED_HEAD_SHA}" + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Compile common metadata and run Android JVM tests + run: >- + ./gradlew + :compose:compileCommonMainKotlinMetadata + :compose:testDebugUnitTest + --no-daemon + + - name: Report Android JVM test count + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + reports = sorted(Path("compose/build/test-results").glob("**/TEST-*.xml")) + if not reports: + raise SystemExit("No Android JVM test result XML files found") + + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + for report in reports: + suite = ET.parse(report).getroot() + for key in totals: + totals[key] += int(suite.attrib.get(key, "0")) + + print( + "android_jvm_test_count " + + " ".join(f"{key}={value}" for key, value in totals.items()) + ) + if totals["tests"] == 0 or totals["failures"] or totals["errors"]: + raise SystemExit(1) + PY diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt index f70e09f4f..21d3c42c2 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/KNode.kt @@ -149,7 +149,13 @@ internal class KNode>( // represent semantically different content. Always redraw so reset() -> flush() clears // render-only state (for example borderRadius and clipPath) left by the previous item, // even when the replacement modifiers are structurally equal and emit no invalidation. - invalidateDraw() + // + // Reuse can start while this node is already dirty because onDeactivate/resetModifierState + // invalidated it under its previous parent. A normal invalidateDraw() is intentionally a + // no-op in that state, but the new parent may still be clean and therefore skip traversing + // this dirty child. Force propagation across the reuse boundary so the pending redraw is + // reachable from the new tree. + invalidateDrawForReuse() } override fun onRelease() { @@ -241,6 +247,22 @@ internal class KNode>( } } + internal fun invalidateDrawForReuse() { + drawInvalidated = true + val currentParent = parent + if (currentParent is KNode<*>) { + currentParent.invalidateDrawForReuse() + } else { + currentParent?.invalidateDraw() + } + } + + internal fun clearDrawInvalidationForTest() { + drawInvalidated = false + } + + internal fun isDrawInvalidatedForTest(): Boolean = drawInvalidated + override fun onWillStartMeasure() { super.onWillStartMeasure() kuiklyCoordinates = null diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeDrawInvalidationTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeDrawInvalidationTest.kt new file mode 100644 index 000000000..caf2713d6 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeDrawInvalidationTest.kt @@ -0,0 +1,114 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.compose.ui.node + +import com.tencent.kuikly.compose.ui.focus.FocusOwner +import com.tencent.kuikly.compose.ui.graphics.Canvas +import com.tencent.kuikly.compose.ui.input.InputModeManager +import com.tencent.kuikly.compose.ui.modifier.ModifierLocalManager +import com.tencent.kuikly.compose.ui.platform.KuiklySoftwareKeyboardController +import com.tencent.kuikly.compose.ui.platform.ViewConfiguration +import com.tencent.kuikly.compose.ui.unit.Constraints +import com.tencent.kuikly.compose.ui.unit.Density +import com.tencent.kuikly.compose.ui.unit.LayoutDirection +import com.tencent.kuikly.core.base.DeclarativeBaseView +import com.tencent.kuikly.core.views.DivView +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KNodeDrawInvalidationTest { + + @Test + fun reusePropagatesDirtyChildInvalidationToItsNewParent() { + val root = KNode>(DivView()) + val parent = KNode(DivView()) + val child = KNode(DivView()) + root.insertAt(0, parent) + parent.insertAt(0, child) + val owner = TestOwner(root) + root.attach(owner) + root.clearDrawInvalidationForTest() + + // Both KNodes start dirty. Normal invalidation coalesces at the child and therefore cannot + // wake a clean ancestor after this subtree crosses a reuse boundary. + child.invalidateDraw() + assertFalse(root.isDrawInvalidatedForTest()) + + child.onReuse() + assertTrue(root.isDrawInvalidatedForTest()) + } + + private class TestOwner( + override val root: KNode> + ) : Owner { + override val sharedDrawScope: LayoutNodeDrawScope + get() = error("not used") + override val rootForTest: RootForTest + get() = error("not used") + override val inputModeManager: InputModeManager + get() = error("not used") + override val density: Density = Density(1f) + override val softwareKeyboardController: KuiklySoftwareKeyboardController + get() = error("not used") + override val focusOwner: FocusOwner + get() = error("not used") + override val layoutDirection: LayoutDirection = LayoutDirection.Ltr + override var showLayoutBounds: Boolean = false + override val measureIteration: Long = 0L + override val viewConfiguration: ViewConfiguration + get() = error("not used") + override val snapshotObserver = OwnerSnapshotObserver { callback -> callback() } + override val modifierLocalManager: ModifierLocalManager + get() = error("not used") + override val coroutineContext: CoroutineContext = EmptyCoroutineContext + + override fun onRequestMeasure( + layoutNode: LayoutNode, + affectsLookahead: Boolean, + forceRequest: Boolean, + scheduleMeasureAndLayout: Boolean + ) = Unit + + override fun onRequestRelayout( + layoutNode: LayoutNode, + affectsLookahead: Boolean, + forceRequest: Boolean + ) = Unit + + override fun requestOnPositionedCallback(layoutNode: LayoutNode) = Unit + override fun onAttach(node: LayoutNode) = Unit + override fun onDetach(node: LayoutNode) = Unit + override fun measureAndLayout(sendPointerUpdate: Boolean) = Unit + override fun measureAndLayout(layoutNode: LayoutNode, constraints: Constraints) = Unit + override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) = Unit + + override fun createLayer( + drawBlock: (Canvas) -> Unit, + invalidateParentLayer: () -> Unit, + view: DeclarativeBaseView<*, *>? + ): OwnedLayer = error("not used") + + override fun onSemanticsChange() = Unit + override fun onLayoutChange(layoutNode: LayoutNode) = Unit + override fun onZIndexChange(layoutNode: LayoutNode) = Unit + override fun registerOnEndApplyChangesListener(listener: () -> Unit) = Unit + override fun onEndApplyChanges() = Unit + override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) = Unit + } +} From b2a2eb03753141a1ab892ef8de0f7baeef19616c Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Thu, 16 Jul 2026 18:59:11 +0800 Subject: [PATCH 121/187] fix(android): clear decoration before view reuse (#53) Clear Android decoration-owned background, foreground, outline, and clip state before native View pool reuse so the next owner rebuilds from a clean state.\n\nSigned-off-by: Android-Developer-1 --- .github/workflows/android-render-pr.yml | 119 ++++++++++++++++++ core-render-android/build.2.1.21.gradle.kts | 1 + .../css/decoration/KRViewDecoration.kt | 20 ++- .../android/css/ktx/KRCSSViewExtension.kt | 19 ++- .../css/ktx/KRCSSDecorationReuseTest.kt | 75 +++++++++++ 5 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/android-render-pr.yml create mode 100644 core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSDecorationReuseTest.kt diff --git a/.github/workflows/android-render-pr.yml b/.github/workflows/android-render-pr.yml new file mode 100644 index 000000000..7279a5dea --- /dev/null +++ b/.github/workflows/android-render-pr.yml @@ -0,0 +1,119 @@ +name: Android Render PR + +on: + pull_request: + branches: + - staging2 + paths: + - "core-render-android/**" + - "buildSrc/**" + - "gradle/**" + - "build.gradle*" + - "settings.gradle*" + - "gradle.properties" + - ".github/workflows/android-render-pr.yml" + +permissions: + contents: read + +jobs: + android-render-jvm: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out exact PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Verify exact PR head + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + echo "expected_head_sha=${EXPECTED_HEAD_SHA}" + echo "actual_head_sha=${actual_head_sha}" + test "${actual_head_sha}" = "${EXPECTED_HEAD_SHA}" + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Android render JVM tests + run: >- + ./gradlew + :core-render-android:testDebugUnitTest + --no-daemon + + - name: Report Android render JVM test count + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + reports = sorted(Path("core-render-android/build/test-results").glob("**/TEST-*.xml")) + if not reports: + raise SystemExit("No Android render JVM test result XML files found") + + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + for report in reports: + suite = ET.parse(report).getroot() + for key in totals: + totals[key] += int(suite.attrib.get(key, "0")) + + print( + "android_render_jvm_test_count " + + " ".join(f"{key}={value}" for key, value in totals.items()) + ) + if totals["tests"] == 0 or totals["failures"] or totals["errors"]: + raise SystemExit(1) + PY + + - name: Prove stale-radius regression catches old reset wiring + run: | + python3 - <<'PY' + from pathlib import Path + + source = Path( + "core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/" + "KRCSSViewExtension.kt" + ) + text = source.read_text() + fixed = "\n".join([ + " KRCssConst.BORDER_RADIUS -> {", + " resetDecorationForReuse()", + " return true", + " }", + "", + ]) + old = "\n".join([ + " KRCssConst.BORDER_RADIUS -> {", + " background = null", + " destroyViewDecorator()", + " return true", + " }", + "", + ]) + if text.count(fixed) != 1: + raise SystemExit("Expected exactly one fixed BORDER_RADIUS reset block") + source.write_text(text.replace(fixed, old)) + PY + + set +e + ./gradlew \ + :core-render-android:testDebugUnitTest \ + --tests '*KRCSSDecorationReuseTest*' \ + --rerun-tasks \ + --no-daemon + mutation_status=$? + set -e + + git checkout -- \ + core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSViewExtension.kt + test "${mutation_status}" -ne 0 diff --git a/core-render-android/build.2.1.21.gradle.kts b/core-render-android/build.2.1.21.gradle.kts index a73b4f809..7c3d8955a 100644 --- a/core-render-android/build.2.1.21.gradle.kts +++ b/core-render-android/build.2.1.21.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation("androidx.appcompat:appcompat:1.2.0") implementation("androidx.dynamicanimation:dynamicanimation:1.0.0") testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.12.2") // task #476: real org.json for JVM unit tests (the android.jar stubs // throw "not mocked") — needed by KuiklyRenderExtensionMarshalTest. testImplementation("org.json:json:20231013") diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/decoration/KRViewDecoration.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/decoration/KRViewDecoration.kt index 9fafa17b6..83c547889 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/decoration/KRViewDecoration.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/decoration/KRViewDecoration.kt @@ -222,6 +222,24 @@ class KRViewDecoration(targetView: View) : IKRViewDecoration { return isCustomClipPathMode } + /** + * Clears every native drawable/clip surface owned by this decoration before the View enters + * the reuse pool. Radius is copied into the foreground border drawable, so dropping only the + * background and decorator metadata can leave rounded foreground state on the next owner. + */ + internal fun resetForReuse() { + targetViewWeakRef.get()?.also { view -> + view.background = null + if (!isBeforeM) { + view.foreground = null + } + view.outlineProvider = null + view.clipToOutline = false + view.invalidate() + } + customForegroundDrawable = null + } + private fun clipPath(w: Int, h: Int, canvas: Canvas) { if (!needClip) { // 没有设置圆角或路径的情况 return @@ -687,4 +705,4 @@ class BoxShadow(shadowValue: String, private val context: IKuiklyRenderContext?) return shadowOffsetY == 0.0f && shadowOffsetX == 0.0f } -} \ No newline at end of file +} diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSViewExtension.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSViewExtension.kt index 4556ef835..ab0368fb8 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSViewExtension.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSViewExtension.kt @@ -260,7 +260,7 @@ fun View.resetCommonProp(propKey: String): Boolean { return true } KRCssConst.BACKGROUND_COLOR -> { - resetHRBackground() + resetDecorationForReuse() return true } KRCssConst.TOUCH_ENABLE -> { @@ -272,19 +272,19 @@ fun View.resetCommonProp(propKey: String): Boolean { return true } KRCssConst.BACKGROUND_IMAGE -> { - resetHRBackground() + resetDecorationForReuse() return true } KRCssConst.BOX_SHADOW -> { - resetHRBackground() + resetDecorationForReuse() return true } KRCssConst.BORDER_RADIUS -> { - resetHRBackground() + resetDecorationForReuse() return true } KRCssConst.BORDER -> { - resetBorder() + resetDecorationForReuse() return true } KRCssConst.CLICK -> { @@ -534,16 +534,13 @@ private var View.borderStyle: String? /** * 重置View的background */ -private fun View.resetHRBackground() { +private fun View.resetDecorationForReuse() { + optViewDecorator()?.resetForReuse() background = null - destroyViewDecorator() -} - -private fun View.resetBorder() { - destroyViewDecorator() if (!isBeforeM) { foreground = null } + destroyViewDecorator() } /** diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSDecorationReuseTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSDecorationReuseTest.kt new file mode 100644 index 000000000..97a09e591 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRCSSDecorationReuseTest.kt @@ -0,0 +1,75 @@ +/* + * Tencent is pleased to support the open source community by making KuiklyUI + * available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the License of KuiklyUI; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.kuikly.core.render.android.css.ktx + +import android.view.View +import com.tencent.kuikly.core.render.android.const.KRCssConst +import com.tencent.kuikly.core.render.android.css.drawable.KRCSSBackgroundDrawable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class KRCSSDecorationReuseTest { + + @Test + fun radiusResetClearsForegroundClipAndAllowsSquareBorderOnSameViewRepeatedly() { + val view = View(RuntimeEnvironment.getApplication()) + + repeat(2) { + applyRoundedBackground(view) + val reusedIdentity = view + + assertNotNull(view.background) + assertNotNull(view.foreground) + assertNotNull(view.outlineProvider) + + assertEquals(true, view.resetCommonProp(KRCssConst.BORDER_RADIUS)) + + assertSame(reusedIdentity, view) + assertNull(view.background) + assertNull(view.foreground) + assertNull(view.optViewDecorator()) + + assertEquals(true, view.setCommonProp(KRCssConst.BORDER, SQUARE_BORDER)) + val squareBorder = view.foreground as KRCSSBackgroundDrawable + assertEquals(KRCssConst.EMPTY_STRING, squareBorder.borderRadius) + assertEquals(SQUARE_BORDER, squareBorder.borderStyle) + + assertEquals(true, view.resetCommonProp(KRCssConst.BORDER)) + assertNull(view.foreground) + assertNull(view.optViewDecorator()) + } + } + + private fun applyRoundedBackground(view: View) { + assertEquals(true, view.setCommonProp(KRCssConst.BACKGROUND_COLOR, YELLOW)) + assertEquals(true, view.setCommonProp(KRCssConst.BORDER_RADIUS, ROUNDED_RADIUS)) + } + + private companion object { + const val YELLOW = "4294967040" + const val ROUNDED_RADIUS = "10,10,10,10" + const val SQUARE_BORDER = "2 solid 4294901760" + } +} From 2527b9ec63a241b6d4e038678145d23752b2fe11 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 17 Jul 2026 08:42:20 +0800 Subject: [PATCH 122/187] fix(compose): never dispatch off-target programmatic scroll echoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task #318: consume off-target programmatic native scroll echoes as bookkeeping so deferred alignment does not dispatch phantom user scrolling and compose a bottom-anchored list to item 0. Includes deterministic exact-echo, off-target, drag, no-pending, and single-shot regressions.\n\nSigned-off-by: CC-希乐 --- .../compose/gestures/KuiklyScrollInfo.kt | 37 +++++++++ .../compose/ui/layout/SubcomposeLayout.kt | 16 +++- .../compose/gestures/KuiklyScrollInfoTest.kt | 76 +++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 4ad914d09..5900b26ee 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -103,6 +103,43 @@ class KuiklyScrollInfo { return matched } + /** + * Disposition of a native scroll callback relative to a pending programmatic + * offset move ([ignoreScrollOffset]). + * + * A programmatic move ([applyOffsetDelta]) can land somewhere other than its + * recorded target: the native scroller clamps against its own (asynchronously + * updated) content size, or splits one move into several callbacks. Such an + * off-target callback is still an echo of our own move — never user input. + * Interpreting it as a user scroll dispatches a large phantom delta into + * compose; on a bottom-anchored list whose content size is still estimated + * this feeds the expand/align retry loop and serially composes every row up + * to the list start, blocking the Kotlin thread for seconds (task #318). + */ + internal enum class NativeScrollEventDisposition { + /** Exact echo of the programmatic move: drop the event entirely. */ + Consume, + /** Off-target echo of the programmatic move: adopt the reported offset + * into bookkeeping, but never dispatch a compose scroll. */ + SyncOnly, + /** Genuine scroll: dispatch to compose. */ + Dispatch + } + + internal fun resolveNativeScrollEvent( + offsetX: Float, + offsetY: Float, + epsilon: Double, + ): NativeScrollEventDisposition { + val hadPendingProgrammaticMove = ignoreScrollOffset != null + val matched = consumeIgnoredScrollOffset(offsetX, offsetY, epsilon) + return when { + matched -> NativeScrollEventDisposition.Consume + hadPendingProgrammaticMove && !isDragging -> NativeScrollEventDisposition.SyncOnly + else -> NativeScrollEventDisposition.Dispatch + } + } + /** * Scroll view instance */ diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 29dd9504e..85f8fd606 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt @@ -333,13 +333,25 @@ fun SubcomposeLayout( (scrollableState as? DrawerInternalPagerState)?.onNativeContentOffsetChanged(offset) kuiklyInfo.isDragging = kuiklyInfo.scrollView?.isDragging ?: false - if (kuiklyInfo.consumeIgnoredScrollOffset( + when ( + kuiklyInfo.resolveNativeScrollEvent( offsetX = scaleParams.offsetX, offsetY = scaleParams.offsetY, epsilon = 0.5 * kuiklyInfo.getDensity(), ) ) { - return@scroll + KuiklyScrollInfo.NativeScrollEventDisposition.Consume -> return@scroll + KuiklyScrollInfo.NativeScrollEventDisposition.SyncOnly -> { + // Off-target echo of our own programmatic move (native + // clamped or split it). Adopt the reported offset so + // future deltas use the true base, but do not dispatch + // a compose scroll: offsetDirty stays set, so a later + // alignment pass converges once the render-side content + // size has caught up (task #318 joint first-open stall). + kuiklyInfo.composeOffset = offset.toFloat() + return@scroll + } + KuiklyScrollInfo.NativeScrollEventDisposition.Dispatch -> Unit } // 忽略较小的滑动 diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt index 5b5d4a8fd..9ef7fc1e6 100644 --- a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt @@ -17,6 +17,7 @@ package com.tencent.kuikly.compose.gestures import com.tencent.kuikly.compose.ui.unit.IntOffset import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -42,4 +43,79 @@ class KuiklyScrollInfoTest { assertTrue(info.consumeIgnoredScrollOffset(offsetX = 0f, offsetY = 120f, epsilon = 0.5)) assertNull(info.ignoreScrollOffset) } + + // task #318: an off-target echo of a programmatic move (native clamped or + // split it) must never be dispatched to compose as a phantom user scroll — + // that phantom walked a bottom-anchored 50-row list to the top, serially + // composing every row and stalling the Kotlin thread for seconds. + @Test + fun exactProgrammaticEchoIsConsumed() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 120) + } + + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.Consume, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 120f, epsilon = 0.5) + ) + assertNull(info.ignoreScrollOffset) + } + + @Test + fun offTargetProgrammaticEchoSyncsWithoutDispatch() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 4200) + isDragging = false + } + + // Native clamped the applied 4200 down to 118: still our own move's + // echo, so bookkeeping may sync but compose must not scroll. + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.SyncOnly, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 118f, epsilon = 0.5) + ) + assertNull(info.ignoreScrollOffset) + } + + @Test + fun offTargetEchoWhileUserDragsStillDispatches() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 4200) + isDragging = true + } + + // A finger on the screen owns the viewport: never swallow real input. + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.Dispatch, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 118f, epsilon = 0.5) + ) + } + + @Test + fun eventWithoutPendingProgrammaticMoveDispatches() { + val info = KuiklyScrollInfo().apply { isDragging = false } + + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.Dispatch, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 118f, epsilon = 0.5) + ) + } + + @Test + fun programmaticEchoGuardIsSingleShot() { + val info = KuiklyScrollInfo().apply { + ignoreScrollOffset = IntOffset(x = 0, y = 4200) + isDragging = false + } + + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.SyncOnly, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 118f, epsilon = 0.5) + ) + // The follow-up event has no pending move recorded: genuine scroll. + assertEquals( + KuiklyScrollInfo.NativeScrollEventDisposition.Dispatch, + info.resolveNativeScrollEvent(offsetX = 0f, offsetY = 130f, epsilon = 0.5) + ) + } } From cea98d34d5097864767eca2f8b8c9b20e36dd182 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Sat, 18 Jul 2026 10:47:06 +0800 Subject: [PATCH 123/187] sync: absorb Tencent runtime fixes batch 1+3 (#55) Cherry-picked from tencent/main: - 9280a9c3 fix(compose): round viewportSize dp->px conversion (#1530) - 2cc529ce fix(ios): avoid pager snap end hard-jump from spring flush miss (#1538) - 7afa0f77 fix(iOS): align isTurboModule state with page created lifecycle (#1537) - 4a0e01ad fix(ohos): default damping arg for SetArkUIContentOffset (#1534) - 3a8c4e34 fix(miniApp): fix req timeout setting invalid bug (#1543) - 0c6b6f14 fix(h5): h5 multi module umd patch doc (#1540) - 3302c0e6 docs: add 393dp unified design width best practice guide (#1467) --- .../compose/foundation/pager/PagerState.kt | 13 ++ .../compose/gestures/KuiklyScrollInfo.kt | 7 +- .../Extension/Components/KRScrollView.m | 10 ++ .../KuiklyTurboDisplayRenderLayerHandler.m | 14 +- .../cpp/libohos_render/utils/KRViewUtil.h | 2 +- .../web/expand/module/KRNetworkModule.kt | 18 ++- .../render/web/runtime/miniapp/MiniGlobal.kt | 22 +++ .../demo/pages/demo/NetworkExamplePage.kt | 60 ++++++++ docs/DevGuide/font-size-and-display-scale.md | 133 ++++++++++++++++++ docs/sidebar/zh.ts | 1 + h5App/README.md | 50 +++++++ .../webpack.config.d/kuikly-umd-deep-merge.js | 132 +++++++++++++++++ 12 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 docs/DevGuide/font-size-and-display-scale.md create mode 100644 h5App/webpack.config.d/kuikly-umd-deep-merge.js diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/PagerState.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/PagerState.kt index 21c1b56ab..7eba1da71 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/PagerState.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/pager/PagerState.kt @@ -453,6 +453,8 @@ abstract class PagerState internal constructor( private var snapStallAlignmentRetryRequested = false + private var snapLastObservedContentOffset = 0 + /** Called before native setContentOffset(animated=true). */ internal fun markSnapAnimationStarted( targetContentOffset: Int, @@ -469,6 +471,7 @@ abstract class PagerState internal constructor( kuiklyInfo.snapAnchorOffsetCorrection = 0 snapTargetReachedAlignmentRequested = false snapStallAlignmentRetryRequested = false + snapLastObservedContentOffset = kuiklyInfo.contentOffset scrollPosition.clearSnapAnchorPageDuringDrag() pagerSnapDebugLog { "snapStarted: stateId=$debugPagerStateId orientation=${layoutInfo.orientation} " + @@ -488,7 +491,15 @@ abstract class PagerState internal constructor( return } + val offsetChanged = contentOffset != snapLastObservedContentOffset + if (offsetChanged) { + snapLastObservedContentOffset = contentOffset + snapStallAlignmentRetryRequested = false + } if (!hasSnapReachedTarget(contentOffset)) { + if (offsetChanged) { + scheduleScrollViewOffsetAlignment(SNAP_MEASURE_JOB_INITIAL_DELAY_MS) + } return } @@ -520,6 +531,7 @@ abstract class PagerState internal constructor( snapStartDesyncPages = 0 snapTargetReachedAlignmentRequested = false snapStallAlignmentRetryRequested = false + snapLastObservedContentOffset = 0 kuiklyInfo.snapAnchorOffsetCorrection = 0 kuiklyInfo.appleScrollViewOffsetJob?.cancel(ScrollViewOffsetAlignmentCancellation) } @@ -959,6 +971,7 @@ abstract class PagerState internal constructor( snapStartDesyncPages = 0 snapTargetReachedAlignmentRequested = false snapStallAlignmentRetryRequested = false + snapLastObservedContentOffset = 0 kuiklyInfo.snapAnchorOffsetCorrection = 0 } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 5900b26ee..c728d5093 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt @@ -28,6 +28,7 @@ import com.tencent.kuikly.core.pager.PageData import com.tencent.kuikly.core.views.ScrollerAttr import com.tencent.kuikly.core.views.ScrollerEvent import com.tencent.kuikly.core.views.ScrollerView +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -354,7 +355,11 @@ class KuiklyScrollInfo { } else { scrollView?.renderView?.currentFrame?.width ?: 0f } - return (size * getDensity()).toInt() + // Use roundToInt instead of toInt to avoid truncating the dp→px conversion. + // A non-integer density (e.g. 2.625) makes the truncated viewportSize lose ~1px, + // which keeps toButtomDelta at 1 instead of 0 and breaks the bottom overscroll + // bounce handling (lastScrolledBackward wrongly set to true). + return (size * getDensity()).roundToInt() } /** diff --git a/core-render-ios/Extension/Components/KRScrollView.m b/core-render-ios/Extension/Components/KRScrollView.m index f664514bc..b4e856f56 100644 --- a/core-render-ios/Extension/Components/KRScrollView.m +++ b/core-render-ios/Extension/Components/KRScrollView.m @@ -849,6 +849,11 @@ - (void)p_springAnimationWithContentOffset:(CGPoint)contentOffset duration:(CGFl [self setContentOffset:contentOffset]; } completion:^(BOOL finished) { + // OffsetAnimator samples presentationLayer via DisplayLink and is cancelled here + // without a final callback. Flush model contentOffset so Compose/Kotlin reaches target. + if (finished) { + [self dispatchScrollEventWithCurOffset:self.contentOffset]; + } [animator cancel]; }]; } @@ -867,6 +872,11 @@ - (void)p_springAnimationWithContentOffset:(CGPoint)contentOffset duration:(CGFl } [self setContentOffset:contentOffset]; } completion:^(BOOL finished) { + // OffsetAnimator samples presentationLayer via DisplayLink and is cancelled here + // without a final callback. Flush model contentOffset so Compose/Kotlin reaches target. + if (finished) { + [self dispatchScrollEventWithCurOffset:self.contentOffset]; + } [animator cancel]; }]; } diff --git a/core-render-ios/Handler/KuiklyTurboDisplay/KuiklyTurboDisplayRenderLayerHandler.m b/core-render-ios/Handler/KuiklyTurboDisplay/KuiklyTurboDisplayRenderLayerHandler.m index 23712f1e9..5e2dbcd4e 100644 --- a/core-render-ios/Handler/KuiklyTurboDisplay/KuiklyTurboDisplayRenderLayerHandler.m +++ b/core-render-ios/Handler/KuiklyTurboDisplay/KuiklyTurboDisplayRenderLayerHandler.m @@ -17,7 +17,6 @@ #import "KuiklyRenderLayerHandler.h" #import "KRTurboDisplayNode.h" #import "KuiklyRenderUIScheduler.h" -#import "KRTurboDisplayModule.h" #import "KRTurboDisplayCacheManager.h" #import "KRTurboDisplayShadow.h" #import "KRMemoryCacheModule.h" @@ -98,6 +97,15 @@ - (instancetype)initWithRootView:(UIView *)rootView contextParam:(KuiklyContextP _extraCacheContent = [[KRTurboDisplayCacheManager sharedInstance] extraCacheContentWithCacheKey:self.turboDisplayCacheKey]; NSLog(@"[读出] _extraCacheContent:%@", _extraCacheContent); + // 提前标记 firstScreenTurboDisplay,让 Kotlin 侧 created() 中能拿到正确结果 + // init 早于 didInit(didInit 里 nodeWithCachKey 读完即删,之后文件不再存在), + // 必须在 init 阶段用 hasNodeWithCacheKey 预判,再在 didInit 中真正加载。 + if ([[KRTurboDisplayCacheManager sharedInstance] hasNodeWithCacheKey:self.turboDisplayCacheKey]) { + KRTurboDisplayModule *module = (KRTurboDisplayModule *)[_renderLayerHandler moduleWithName:NSStringFromClass([KRTurboDisplayModule class])]; + module.firstScreenTurboDisplay = YES; + [KRLogModule logInfo:[NSString stringWithFormat:@"[TurboDisplay] init: 检测到缓存文件存在,提前标记 firstScreenTurboDisplay=YES"]]; + } + // 更新 TurboDisplayModuleMethod 强制刷新TurboDispla缓存 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onReceiveSetCurrentUINotification:) @@ -137,8 +145,8 @@ - (void)didInit { if ([_turboDisplayCacheData.turboDisplayNode isKindOfClass:[KRTurboDisplayNode class]]) { _lazyRendering = YES; // 存在TB缓存,更新懒渲染标志 _turboDisplayCacheData.extraCacheContent = _extraCacheContent; // 业务自定义缓存,与TB缓存存储于同一对象 - KRTurboDisplayModule *module = (KRTurboDisplayModule *)[_renderLayerHandler moduleWithName:NSStringFromClass([KRTurboDisplayModule class])]; - module.firstScreenTurboDisplay = YES; + + // 【日志】缓存读取成功 [KRLogModule logInfo:[NSString stringWithFormat:@"[TurboDisplay] turboDisplay file read successfully"]]; diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h index 3cf839674..55353652b 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRViewUtil.h @@ -187,7 +187,7 @@ void SetArkUIScrollEnabled(ArkUI_NodeHandle handle, bool enable); KRPoint GetArkUIScrollContentOffset(ArkUI_NodeHandle handle); void SetArkUIContentOffset(ArkUI_NodeHandle handle, float offset_x, float offset_y, bool animate, int duration, int curve, - float damping); + float damping = 0); ArkUI_ScrollState GetArkUIScrollerState(ArkUI_NodeEvent *event, int scroll_state_index); diff --git a/core-render-web/base/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/expand/module/KRNetworkModule.kt b/core-render-web/base/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/expand/module/KRNetworkModule.kt index fa3529afe..64a93b208 100644 --- a/core-render-web/base/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/expand/module/KRNetworkModule.kt +++ b/core-render-web/base/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/expand/module/KRNetworkModule.kt @@ -77,7 +77,14 @@ class KRNetworkModule : KuiklyRenderBaseModule() { body = if (method == HTTP_METHOD_POST) getPostParams(param) else null, // Request mode is cross-domain mode mode = RequestMode.CORS - ) + ).also { + // Attach the timeout via a non-standard field so that MiniGlobal.fetch in the + // mini program runtime can forward it to wx.request. Browsers ignore unknown + // RequestInit fields, so this is safe for the H5 environment as well. + if (timeout > 0) { + it.asDynamic()["timeout"] = timeout + } + } ) // Timeout and response, whoever comes first is processed first Promise.race(arrayOf(requestTimeoutPromise, fetchPromise.unsafeCast>())) @@ -182,7 +189,14 @@ class KRNetworkModule : KuiklyRenderBaseModule() { body = body.toBlob(), // Request mode is cross-domain mode mode = RequestMode.CORS - ) + ).also { + // Attach the timeout via a non-standard field so that MiniGlobal.fetch in the + // mini program runtime can forward it to wx.request. Browsers ignore unknown + // RequestInit fields, so this is safe for the H5 environment as well. + if (timeout > 0) { + it.asDynamic()["timeout"] = timeout + } + } ) // Timeout and response, whoever comes first is processed first Promise.race(arrayOf(requestTimeoutPromise, fetchPromise.unsafeCast>())) diff --git a/core-render-web/miniapp/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/runtime/miniapp/MiniGlobal.kt b/core-render-web/miniapp/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/runtime/miniapp/MiniGlobal.kt index 36ad47188..e5c5792b1 100644 --- a/core-render-web/miniapp/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/runtime/miniapp/MiniGlobal.kt +++ b/core-render-web/miniapp/src/jsMain/kotlin/com/tencent/kuikly/core/render/web/runtime/miniapp/MiniGlobal.kt @@ -536,6 +536,16 @@ object MiniGlobal { headers?.asDynamic()?.forEach { key, value, _ -> reqHeaders.set(key, value) } + // Read the extended `timeout` field from RequestInit (attached by the caller via asDynamic). + // Standard Web RequestInit has no `timeout`; browsers ignore unknown fields, so appending + // this field on the Web side is safe, and here we forward it to wx.request so that the + // native timeout can align with the JS-layer Promise.race timeout. + val timeoutValue = init?.asDynamic()?.timeout + val timeout: Int? = if (jsTypeOf(timeoutValue) == "number" && (timeoutValue.unsafeCast()) > 0) { + timeoutValue.unsafeCast() + } else { + null + } // real mini app request NativeApi.plat.request(MiniRequestInit( // Request URL @@ -551,6 +561,9 @@ object MiniGlobal { dataType = if (isStream) "" else "json", // Default is text responseType = if (isStream) "arraybuffer" else "text", + // Request timeout in milliseconds, forwarded from the upper layer to keep the + // underlying wx.request timeout consistent with the JS Promise.race timeout + timeout = timeout, // Request success callback success = { rsp: Any -> resolveFun?.invoke(MiniResponse(rsp).unsafeCast()) @@ -588,6 +601,7 @@ object MiniGlobal { data: dynamic = undefined, dataType: String? = "json", responseType: String? = "text", + timeout: Int? = null, success: (Any) -> Unit = {}, fail: (Any) -> Unit = {} ): MiniRequestInit { @@ -598,6 +612,11 @@ object MiniGlobal { o["data"] = data o["dataType"] = dataType o["responseType"] = responseType + // Only set timeout when the caller provided a valid positive value; otherwise let + // wx.request fall back to the mini program global networkTimeout.request setting. + if (timeout != null && timeout > 0) { + o["timeout"] = timeout + } o["success"] = success o["fail"] = fail return o.unsafeCast() @@ -647,6 +666,9 @@ external interface MiniRequestInit { var responseType: String? get() = definedExternally set(value) = definedExternally + var timeout: Int? + get() = definedExternally + set(value) = definedExternally var success: (Any) -> Unit var fail: (Any) -> Unit } diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/NetworkExamplePage.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/NetworkExamplePage.kt index 8140d9714..489361152 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/NetworkExamplePage.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/NetworkExamplePage.kt @@ -20,6 +20,7 @@ import com.tencent.kuikly.core.base.Border import com.tencent.kuikly.core.base.BorderStyle import com.tencent.kuikly.core.base.Color import com.tencent.kuikly.core.base.ViewBuilder +import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.module.NetworkModule import com.tencent.kuikly.core.nvi.serialization.json.JSONObject import com.tencent.kuikly.core.reactive.handler.observable @@ -156,6 +157,27 @@ internal class NetworkExamplePage: BasePager() { } } } + Button { + attr { + size(150f, 40f) + borderRadius(20f) + marginLeft(10f) + marginTop(5f) + backgroundColor(Color(0x6200ee, 1f)) + titleAttr { + text("longTimeout(90s)") + color(Color.WHITE) + } + highlightBackgroundColor(Color.GRAY) + } + event { + click { + ctx.output = "requestLongTimeout... (expecting ~70s success or ~90s timeout)" + ctx.src = "" + ctx.requestLongTimeout() + } + } + } } View { attr { @@ -285,6 +307,44 @@ internal class NetworkExamplePage: BasePager() { } } + /** + * Regression case for the wx.request timeout propagation fix. + * + * Explicitly asks for a 90s timeout and hits an endpoint that delays the response for 70s. + * + * Expected behavior: + * - Before fix (mini program runtime): wx.request falls back to the default 60s timeout, + * the request fails at ~60s with a timeout error, and the upper-layer 90s Promise.race + * never wins. + * - After fix: the 90s timeout is forwarded to wx.request, so the delayed response is + * received successfully around 70s. + */ + private fun requestLongTimeout() { + val startMs = DateTime.currentTimestamp() + acquireModule(NetworkModule.MODULE_NAME).httpRequest( + url = "https://httpbin.org/delay/70", + isPost = false, + param = JSONObject().apply { put("key", "value") }, + headers = null, + cookie = null, + timeout = 90 + ) { data, success, errorMsg, response -> + val elapsedMs = DateTime.currentTimestamp() - startMs + output = """Long-timeout request completed: + | elapsedMs=$elapsedMs (expect ~70000 after fix; ~60000 before fix) + | + | success=$success, + | + | data=$data, + | + | errorMsg=$errorMsg, + | + | statusCode=${response.statusCode}, + | + | headers=${response.headerFields}""".trimMargin() + } + } + private fun ByteArray.encodeBase64(): String { val table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" val result = StringBuilder((size + 2) / 3 * 4) diff --git a/docs/DevGuide/font-size-and-display-scale.md b/docs/DevGuide/font-size-and-display-scale.md new file mode 100644 index 000000000..597ae6f48 --- /dev/null +++ b/docs/DevGuide/font-size-and-display-scale.md @@ -0,0 +1,133 @@ +# 统一设计尺寸与字号缩放最佳实践 + +## 适用场景 + +跨端业务通常只产出**一套设计稿**(例如以 iPhone 的 `393` 逻辑宽度为基准),却希望在所有设备、所有平台上呈现**完全一致的视觉尺寸与比例**。 + +要做到这一点有两个相互独立的关注点,按需选用: + +| 关注点 | 作用 | 支持平台 | +|--------|------|---------| +| **统一设计宽度(核心)** | 让整页逻辑坐标宽度在所有设备上恒等于设计基准(如 393),实现"一套尺寸跨端一致" | Android(对齐)/ iOS(基准) | +| **字号缩放** | 文本字号是否跟随系统「字体大小」设置 | Android / iOS / HarmonyOS | + +--- + +## 一、统一设计宽度:393dp 基准(核心) + +### 原理:逻辑宽度 = 屏幕像素宽 ÷ density + +Kuikly 的布局以逻辑单位(Android 为 dp)描述。Android 渲染层把根视图宽度上报给 Kotlin 布局侧时,换算公式是: + +``` +页面逻辑宽度 = 屏幕像素宽(px) / density +``` + +默认 `density` 取系统真机值,于是不同机型算出的逻辑宽度各不相同(如 1080px/2.75 ≈ 392.7,1440px/3.5 ≈ 411.4……)。**同一套 dp 数值在不同设备上显示的物理尺寸/比例就会不一致**,与按 `393` 设计的稿子对不齐。 + +> ⚠️ 常见误区:在 `getDisplayMetrics` 里写死 `density = 2f`。这只是换了个固定缩放比,逻辑宽度变成 `屏幕像素宽/2`,仍随设备变化,**并不能统一尺寸**。 + +### 做法:把逻辑宽度锁定到设计基准 393 + +选定统一设计宽度 `DESIGN_WIDTH = 393`(与 iOS 设计稿基准一致),让每个平台的逻辑宽度都恒等于它: + +- **iOS:作为基准平台**,界面直接按 `393` 设计宽度实现(iOS 以 pt 为逻辑单位)。 +- **Android:通过 FontAdapter 对齐**,把 `density` 动态计算为 `屏幕像素宽 / 393`,反推出"逻辑宽度恒为 393"。 +- **总开关**:在 Delegate 中开启 `useHostDisplayMetrics()`,框架才会采用 FontAdapter 提供的 `DisplayMetrics`。 + +#### Android 实现 + +```kotlin +object KRFontAdapter : IKRFontAdapter { + + // 统一设计宽度基准:与 iOS 设计稿保持一致 + private const val DESIGN_WIDTH_DP = 393f + + override fun getDisplayMetrics(useHostDisplayMetrics: Boolean?): DisplayMetrics { + val system = Resources.getSystem().displayMetrics + // 精华:density 动态 = 真机像素宽 / 393,使「逻辑宽度 = 像素宽 / density」恒等于 393 + val density = system.widthPixels / DESIGN_WIDTH_DP + return DisplayMetrics().apply { + this.density = density + this.scaledDensity = density + this.densityDpi = (density * DisplayMetrics.DENSITY_DEFAULT).toInt() + this.widthPixels = system.widthPixels + this.heightPixels = system.heightPixels + } + } +} +``` + +启用入口(Delegate): + +```kotlin +val delegate = object : KuiklyRenderViewBaseDelegatorDelegate { + override fun useHostDisplayMetrics(): Boolean = true +} +``` + +这样无论真机分辨率多少,Kuikly 页面的逻辑宽度都恒为 `393`,与 iOS 按 `393` 实现的界面在尺寸与比例上一一对齐。 + +> - 设计基准可按团队设计稿调整(如 `375`),三端务必使用同一个值。 +> - 上例以全屏宽度(`widthPixels`)为基准;若 Kuikly 容器不是全屏,应改用容器实际宽度参与计算。 +> - iOS / HarmonyOS 无 Android 这种由系统「显示大小」改变 density 的机制,按基准设计稿实现即可,无需额外对齐代码。 + +--- + +## 二、字号不跟随系统(跨端) + +字号缩放与「统一设计宽度」是**两件事**:前者只影响文字大小,后者影响整页布局换算。字号缩放由 Kotlin 侧总开关控制,端侧再各自实现。 + +### 1. 打开总开关(Kotlin 侧,跨端共用) + +在 `Pager` 中重写 `scaleFontSizeEnable()` 返回 `true`,框架才会把文本字号交给端侧处理;默认 `false` 表示字号不做端侧缩放: + +```kotlin +override fun scaleFontSizeEnable(): Boolean { + return true +} +``` + +### 2. 端侧实现缩放算法 + +#### Android + +在 `IKRFontAdapter` 中重写 `scaleFontSize(fontSize)`,返回最终生效字号。要「不跟随系统字体大小」,原样返回即可: + +```kotlin +override fun scaleFontSize(fontSize: Float): Float { + return fontSize // 不跟随系统字号;按倍率缩放则返回 fontSize * ratio +} +``` + +#### iOS + +实现 `KuiklyFontProtocol` 的 `scaleFitWithFontSize:`,并通过 `registerFontHandler:` 注册: + +```objc +- (CGFloat)scaleFitWithFontSize:(CGFloat)fontSize { + return fontSize; // 不跟随系统字号 +} + +// 注册 +[KuiklyRenderBridge registerFontHandler:[[MyFontHandler alloc] init]]; +``` + +#### HarmonyOS + +在页面控制器中重写 `fontSizeScaleFollowSystem()` 返回 `false`(不跟随系统,缩放比例固定为 1): + +```typescript +fontSizeScaleFollowSystem(): boolean { + return false +} +``` + +--- + +## 注意事项 + +- **两条能力独立**:只想统一布局尺寸就只做「统一设计宽度」;只想锁字号就只做「字号不跟随系统」;可同时启用。 +- **务必先接入字体适配器**:Android `getDisplayMetrics` / `scaleFontSize` 都属于 `IKRFontAdapter`,需先注册 `krFontAdapter`;iOS 需 `registerFontHandler:`。详见各端接入文档:[Android 接入](../QuickStart/android.md)、[iOS 接入](../QuickStart/iOS.md)、[HarmonyOS 接入](../QuickStart/harmony.md)。 +- **总开关易遗漏**:Android / iOS 即使实现了 `scaleFontSize` / `scaleFitWithFontSize:`,若 `Pager.scaleFontSizeEnable()` 仍为默认 `false`,缩放算法不会被调用。 +- **`useHostDisplayMetrics` 易遗漏**:FontAdapter 即使返回了自定义 `DisplayMetrics`,若 Delegate 的 `useHostDisplayMetrics()` 未返回 `true`,框架仍使用系统默认 metrics,统一宽度不生效。 diff --git a/docs/sidebar/zh.ts b/docs/sidebar/zh.ts index eaa71ddec..739ea9a29 100644 --- a/docs/sidebar/zh.ts +++ b/docs/sidebar/zh.ts @@ -107,6 +107,7 @@ export const zhSidebar = sidebar({ "view-external-prop.md", "text-measure.md", "text-post-processor-guide.md", + "font-size-and-display-scale.md", "get-component-size-and-position.md", "protobuf.md", "thread-and-coroutines.md", diff --git a/h5App/README.md b/h5App/README.md index f5f6e384c..ad7ef1cbc 100644 --- a/h5App/README.md +++ b/h5App/README.md @@ -95,6 +95,7 @@ addSplitPages(listOf("实际的页面名称")) h5App是项目的宿主APP,依赖 webRender,构建得到 h5App.js,demo 则是具体业务,构建得到统一的 nativevue2.js 或者是 split 的分页 js 文件。 生产环境部署时 index.html 中会引入具体页面的 nativevue2.js 或 ${pageName}.js,以及 h5App.js,部署生产环境的 html 中业务和 h5App.js 的引用需要根据业务实际情况调整。 + ```html @@ -139,3 +140,52 @@ h5App是项目的宿主APP,依赖 webRender,构建得到 h5App.js,demo 则 web 已支持项目中 assets 目录内图片资源的引用,但需要注意,assets 资源的引用有 ImageUri.pageAssets 和 ImageUri.commonAssets 两种方式,其中 commonAssets 方式引用的是 demo/src/commonMain/assets/common 目录内的图片, pageAssets 方式引用的是 demo/src/commonMain/assets/{pageName}/内的图片,注意这里{pageName}一定是业务Page中@Page注解内的真实pageName,包括大小写,分隔符等。在部署时,需要将 h5App/build/dist/js/productionExecutable/assets 目录 整个拷贝到 web 项目根目录下,这样业务内通过 ImageUrl.pageAssets 和 ImageUri.commonAssets 所拿到的 assets 资源相对路径就能访问到对应的图片资源了 + +## 多模块工程下 UMD 全局命名空间被覆盖问题 + +### 现象 + +在 `enableMultiModule = true` 的多模块工程(例如业务 shared 模块产出 `nativevue2.js`,`h5App` 模块产出 `h5App.js`,由 `JSMultiEntryBuilder` 分别打包)下, +从 Kuikly 2.19.0 起,页面加载后偶发以下错误: + +```text +Cannot read properties of undefined (reading 'registerCallNative') +``` + +即 `window.com.tencent.kuikly.core.nvi` 分支在 `h5App.js` 加载后变成 `undefined`,桥接注册失败。 + +### 根因 + +- `nativevue2.js`(shared 模块)依赖 `core`,其 UMD exports 顶层 `com` 分支下含有 `com.tencent.kuikly.core.nvi.*`,不含 `render.web.*`。 +- `h5App.js`(h5App 模块)依赖 `core-render-web:h5`。2.19+ 起 `core-render-web:base`/`h5` 新增了若干 `@file:JsExport` 顶层文件 + (`KuiklyView` / `IKuiklyView` / `KuiklyRenderViewDelegator` / `JSHelper` 等),使得 `h5App.js` 的 UMD exports 顶层也出现 `com` 键, + 但只含 `com.tencent.kuikly.core.render.web.*`,缺失 `nvi` 分支。 +- kotlin-webpack 生成的 UMD 尾部对 `window` 侧是**逐 key 整体赋值**,不做深合并: + + ```js + var a = factory(); + for (var i in a) (typeof exports === 'object' ? exports : root)[i] = a[i]; + ``` + + 当 `h5App.js` 后加载时,会把它自己的 `com` 整体覆盖到 `window.com`,从而抹掉 `nativevue2.js` 之前挂上的 `com.tencent.kuikly.core.nvi`。 + +单模块工程下所有代码打在同一个产物里,`com.tencent.kuikly.core` 分支合并挂载一次,因此不会出现这种覆盖问题;这也是"单模块项目升级 2.19+ 没有踩坑、多模块项目却报错"的原因。 + +### 处理方法 + +`webpack.config.d/` 下提供两个针对该问题的配置片段,**二选一**启用即可: + +- **方案 X(默认启用,见 `webpack.config.d/output.js`)**:把 `h5App.js` 的输出方式从 UMD 改成 IIFE + (`config.output.libraryTarget = undefined` + `config.output.iife = true`), + 让 `h5App.js` 不再向 `window` 暴露 UMD exports,从根源上避免整体覆盖 `window.com`。 + 适用于 `h5App.js` 本身只作为可执行入口、不需要对外提供符号的场景(当前默认场景)。 + +- **方案 Y(备选,见 `webpack.config.d/kuikly-umd-deep-merge.js`)**:保留 UMD 输出, + 在 emit 之前重写 UMD 尾部,把"逐 key 覆盖挂全局"改成"逐 key 深合并挂全局"——已存在的对象分支做递归合并、 + 已经存在的非对象值优先保留旧值。这样 `nativevue2.js` 与 `h5App.js` 各自挂到 `window.com` 的分支就能共存。 + 适用于集成方仍要求 `h5App.js` 通过 UMD 对外暴露 `KuiklyView` 等符号、或由于历史原因无法关闭 UMD wrapper 的场景。 + +> ⚠️ 请勿同时启用两个方案。启用方案 Y 时,需要把 `output.js` 里 `libraryTarget`/`iife` 相关行注释掉, +> 否则 UMD 尾部会被提前抹掉,方案 Y 的字符串替换将匹配不到而失效。 + +如仅使用官方默认的 h5App 工程结构,保持 `output.js` 现状(方案 X)即可,`kuikly-umd-deep-merge.js` 仅在需要保留 UMD 输出时启用。 diff --git a/h5App/webpack.config.d/kuikly-umd-deep-merge.js b/h5App/webpack.config.d/kuikly-umd-deep-merge.js new file mode 100644 index 000000000..20cac6092 --- /dev/null +++ b/h5App/webpack.config.d/kuikly-umd-deep-merge.js @@ -0,0 +1,132 @@ +/** + * Kuikly UMD Deep-Merge Patch (业务侧一次性方案 Y) + * + * 背景: + * Kuikly >= 2.19.0 的 core-render-web:base / core-render-web:h5 里新增了 + * 若干 `@file:JsExport` 顶层文件(KuiklyView / IKuiklyView / KuiklyRenderViewDelegator / + * JSHelper 及 base 里的 8 个 export 文件),使得 h5App.js 打包产物的 exports + * 顶层出现 `com` 键,且分支只有 `com.tencent.kuikly.core.render.web.*`, + * 缺失 `com.tencent.kuikly.core.nvi`。 + * + * 而 kotlin-webpack 生成的 UMD 头默认是: + * + * var a = factory(); + * for (var i in a) (typeof exports === 'object' ? exports : root)[i] = a[i]; + * + * 这一段会把 h5App.js 产物的 `com` 整体赋给 `window.com`,从而抹掉 + * nativevue2.js 之前挂载的 `window.com.tencent.kuikly.core.nvi.registerCallNative`, + * 导致 KuiklyRenderContextHandler.init() 报 undefined、桥接失败。 + * + * 该问题仅在“多模块工程”(enableMultiModule = true + JSMultiEntryBuilder, + * 业务 shared 与 h5App 分别产出 nativevue2.js / h5App.js 两个 KMM webpack 产物) + * 下才会出现;单模块工程只有一个产物,所有 @JsExport 分支合并到同一棵树中, + * 不存在覆盖问题。 + * + * 修复思路: + * 保留 UMD 头的其它逻辑,仅把最后那段“逐 key 覆盖挂全局”替换为“逐 key + * 深合并挂全局”——针对已经存在于 root 的对象 key,做深度合并而不是整体 + * 替换。这样 nativevue2.js 与 h5App.js 各自挂到 window.com 的分支就能 + * 共存,`com.tencent.kuikly.core.nvi` 分支不再被覆盖。 + * + * 与 output.js 的关系(两套方案二选一): + * 本目录下的 output.js 采用的是“方案 X”:直接把 h5App.js 的 libraryTarget + * 置空、走 iife: true,让 h5App.js 不再暴露任何 UMD exports,也就不会 + * 触碰 window.com。方案 X 更彻底,是当前默认启用的方案。 + * + * 本文件(方案 Y)保留 UMD 输出、但在 emit 前重写 UMD 尾部,改为深合并。 + * 适用于以下场景: + * 1) 某些集成方要求 h5App.js 仍以 UMD 方式对外暴露 KuiklyView 等符号; + * 2) 由于历史原因无法关闭 UMD wrapper; + * 3) 想同时保护 nativevue2.js(shared 模块)产物也做类似合并。 + * + * 如启用本方案,请同时把 output.js 中 `iife`/`libraryTarget` 相关行注释 + * 掉,避免 UMD 尾部被提前抹掉、导致本 patch 的正则匹配不到而失效。 + * + * 说明: + * 这个 patch 只影响 UMD 尾部的“挂全局”分支(浏览器