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/.github/workflows/compose-pr.yml b/.github/workflows/compose-pr.yml new file mode 100644 index 000000000..e43efc5a5 --- /dev/null +++ b/.github/workflows/compose-pr.yml @@ -0,0 +1,272 @@ +name: Compose PR Exact Matrix + +on: + pull_request: + branches: + - staging2 + +permissions: + contents: read + +env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + +jobs: + identity-diff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out exact PR head + uses: actions/checkout@v4 + with: + ref: ${{ env.EXPECTED_HEAD_SHA }} + fetch-depth: 0 + + - name: Verify identity, DCO, diff and clean tree + shell: bash + run: | + set -euo pipefail + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$EXPECTED_HEAD_SHA" + test -z "$(git status --porcelain)" + git diff --check "${{ github.event.pull_request.base.sha }}...HEAD" + # Single-commit exact: exactly one commit on top of the merge base, + # and the head's sole parent is that merge base. + base_sha="${{ github.event.pull_request.base.sha }}" + test "$(git rev-list --count "${base_sha}..HEAD")" = "1" + test "$(git rev-parse HEAD^)" = "$(git merge-base HEAD "${base_sha}")" + author_email="$(git show -s --format=%ae HEAD)" + committer_email="$(git show -s --format=%ce HEAD)" + author_name="$(git show -s --format=%an HEAD)" + # Generic DCO gate (owner-agnostic): author == committer, and the commit + # carries a Signed-off-by trailer matching that identity. + test -n "$author_email" + test "$author_email" = "$committer_email" + git show -s --format=%B HEAD | grep -Fx \ + "Signed-off-by: ${author_name} <${author_email}>" + { + echo "head=$actual_head" + echo "tree=$(git rev-parse HEAD^{tree})" + echo "parent=$(git rev-parse HEAD^)" + echo "base=${{ github.event.pull_request.base.sha }}" + echo "merge_base=$(git merge-base HEAD '${{ github.event.pull_request.base.sha }}')" + echo "author=$(git show -s --format='%an <%ae>' HEAD)" + echo "committer=$(git show -s --format='%cn <%ce>' HEAD)" + echo "patch_id=$(git show --pretty=format: HEAD | git patch-id --stable | awk '{print $1}')" + } | tee exact-manifest.txt + git diff --binary "${{ github.event.pull_request.base.sha }}...HEAD" > candidate.patch + sha256sum candidate.patch | tee candidate.patch.sha256 + + - name: Upload exact identity evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: identity-diff-${{ env.EXPECTED_HEAD_SHA }} + path: | + exact-manifest.txt + candidate.patch.sha256 + + common-core-android: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.EXPECTED_HEAD_SHA }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + - name: Verify exact checkout + run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" + - name: Compile metadata and run production JVM fixtures + run: | + ./gradlew \ + :core:compileCommonMainKotlinMetadata \ + :compose:compileCommonMainKotlinMetadata \ + :core:testDebugUnitTest \ + :compose:testDebugUnitTest \ + :core-render-android:testDebugUnitTest \ + --no-build-cache --no-daemon + - name: Require successful JVM suites and report module counts + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + for module in ("core", "compose", "core-render-android"): + reports = sorted(Path(module, "build", "test-results").glob("**/TEST-*.xml")) + tests = failures = errors = 0 + for report in reports: + suite = ET.parse(report).getroot() + tests += int(suite.attrib.get("tests", "0")) + failures += int(suite.attrib.get("failures", "0")) + errors += int(suite.attrib.get("errors", "0")) + print(f"{module}: tests={tests} failures={failures} errors={errors}") + # The safe pre-transaction core tree has no JVM test sources, so + # :core:testDebugUnitTest is intentionally NO-SOURCE. Compose and + # Android renderer both have real suites and must stay nonzero. + if module != "core" and tests == 0: + raise SystemExit(1) + if failures or errors: + raise SystemExit(1) + PY + - name: Upload JVM evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: common-core-android-${{ env.EXPECTED_HEAD_SHA }} + path: | + core/build/test-results + compose/build/test-results + core-render-android/build/test-results + + ios-renderer: + runs-on: macos-14 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.EXPECTED_HEAD_SHA }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + - uses: gradle/actions/setup-gradle@v4 + - name: Select Xcode 16.2 + run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer + - name: Generate CocoaPods dummy framework + run: ./gradlew :demo:generateDummyFramework --no-build-cache --no-daemon + - name: Install locked Pods + run: bundle exec pod install --project-directory=iosApp --deployment + - name: Compile Kotlin Native main and tests + run: | + ./gradlew \ + :core:compileKotlinIosSimulatorArm64 \ + :compose:compileKotlinIosSimulatorArm64 \ + :compose:compileTestKotlinIosSimulatorArm64 \ + --no-build-cache --no-daemon + - name: Run native text-input sequencing fixture + run: tools/ios-renderer-tests/run-text-input-event-sequencer-test.sh + - name: Build production renderer with warnings as errors + run: | + mkdir -p build + set -o pipefail + xcodebuild \ + -workspace iosApp/iosApp.xcworkspace \ + -scheme OpenKuiklyIOSRender \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + GCC_TREAT_WARNINGS_AS_ERRORS=YES \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \ + -derivedDataPath build/ios-derived \ + -resultBundlePath build/ios-renderer.xcresult \ + build | tee build/ios-renderer.log + - name: Upload iOS evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-renderer-${{ env.EXPECTED_HEAD_SHA }} + path: | + build/ios-renderer.log + build/ios-renderer.xcresult + + ohos-native: + # KuiklyUI is a personal repository, so organization-scoped Blacksmith runners never pick up + # these jobs. Keep the existing pinned HarmonyOS container on the official hosted runner. + runs-on: ubuntu-latest + container: + image: ghcr.io/bytemain/harmony-next-pipeline-docker/harmonyos-ci-image:v6.1.1.280-android.1 + timeout-minutes: 60 + env: + OHOS_SDK_HOME: /opt/harmonyos-tools/command-line-tools/sdk/default/openharmony + DEVECO_SDK_HOME: /opt/harmonyos-tools/command-line-tools/sdk + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.EXPECTED_HEAD_SHA }} + fetch-depth: 0 + - name: Trust the mounted workspace inside the job container + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config --global user.name "Kuikly CI" + git config --global user.email "kuikly-ci@users.noreply.github.com" + - name: Prepare OHOS native dependency metadata + working-directory: ohosApp + run: | + export PATH="$OHOS_SDK_HOME/native/build-tools/cmake/bin:$OHOS_SDK_HOME/native/llvm/bin:$PATH" + ohpm install --all + hvigorw --sync -p product=default --analyze=normal --parallel --no-daemon + hvigorw assembleHar \ + --mode module \ + -p module=render@default \ + -p product=default \ + -p buildMode=debug \ + --analyze=normal \ + --parallel \ + --no-daemon + - name: Fresh arm64 production libkuikly compile and link + shell: bash + run: | + set -euo pipefail + export PATH="$OHOS_SDK_HOME/native/build-tools/cmake/bin:$OHOS_SDK_HOME/native/llvm/bin:$PATH" + tools/ohos-renderer-tests/run-arm64-direct-link.sh "$GITHUB_WORKSPACE/build/ohos-evidence" + - name: Upload OHOS native evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ohos-native-${{ env.EXPECTED_HEAD_SHA }} + path: build/ohos-evidence + + source-contracts: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.EXPECTED_HEAD_SHA }} + fetch-depth: 0 + - name: Reject stale native callbacks retained by Compose node initialization + run: python3 tools/check-compose-node-event-freshness.py --self-test + - name: Preserve active user motion when RecyclerView enters dragging + run: python3 tools/check-android-drag-entry-preserves-physical-scroll.py --self-test + - name: Keep DrawModifierNode view-aware production dispatch + run: python3 tools/check-draw-modifier-view-dispatch.py --self-test + - name: Reject non-finite iOS scroll event values + run: python3 tools/check-ios-scroll-event-finiteness.py --self-test + - name: Preserve profiler observer composition and thread ownership + run: python3 tools/check-profiler-observer-state-partition.py --self-test + - name: Preserve profiler file output across Pager and session lifecycle + run: python3 tools/check-profiler-file-output-lifecycle.py --self-test + - name: Keep OHOS contentInset leading offsets aligned with the margin model + run: core-render-ohos/src/test/cpp/run_scroller_content_inset_offset_test.sh + + exact-matrix: + if: always() + runs-on: ubuntu-latest + needs: + - identity-diff + - common-core-android + - ios-renderer + - ohos-native + - source-contracts + steps: + - name: Require every exact-bound gate + env: + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PY' + import json, os + needs = json.loads(os.environ["RESULTS"]) + failed = {name: value["result"] for name, value in needs.items() if value["result"] != "success"} + print(json.dumps(needs, indent=2, sort_keys=True)) + if failed: + raise SystemExit(f"exact matrix failed: {failed}") + PY diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5f3bdec35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md — bytemain/KuiklyUI fork 协作约定 + +本仓库是 Tencent-TDS/KuiklyUI 的业务 fork,消费方为 botiverse/mobile(third_party/kuikly-ui gitlink)。以下约定来自 2026-07 divergence audit 与 fork 维护复盘(task #39),所有 human 与 agent 贡献者均须遵守。 + +## 铁律 + +1. **所有 KuiklyUI 变更合 `staging2`,并同步更新 mobile gitlink。** 不合 staging2 的修复等于没修;合 staging2 不更新 gitlink 的修复等于没交付(mobile main 分支保护,一律走 PR)。 +2. **仓库禁用 merge commit,PR 一律 squash 合入**,squash message 保留来源信息(cherry-pick 吸收时列 upstream commit 清单)。 + +## staging2 只收终态 + +- 微观试错(padding/margin/几何微调)必须在任务分支内迭代完成,**staging2 只收最终形态**。2026-06~07 的 fork-only 历史里约 8% 是 fix+revert 净零对(如 067c6e07+9bb84eb1、20f9c2a4+8b9623e7),全是把 staging2 当试错分支造成的。 +- PR 内多轮修改请 squash 或 force-push 任务分支,不要把 A/B 试探逐个合入。 + +## 吸收 upstream 前必先查 + +- **查在途/已合 PR**:修上游问题前,先查 Tencent-TDS/KuiklyUI 是否有在途或已合的等价修复(教训:zenipchen OHOS LazyColumn 系列在 fork 自研一遍,实际就是 upstream PR #1478,最后整体 revert,纯重复劳动)。 +- **判断"已吸收"用文件内容,不用 patch-id**:批量 squash 吸收(如 bcacb669)会造成 `git cherry` 假阳性。判据是 `git diff origin/staging2 ^ -- ` + 符号级 grep,不是 cherry 的 `+/-`。 +- 吸收走 divergence audit 批次:upstream commit、影响面、吸收风险、回归点,回执 #Kuiklybase。 + +## 高试错热点必须带锁定测试 + +以下区域被 audit 判定为高试错密度,改动**必须**附带或更新锁定测试,否则不予合入: + +- Android 行高居中(`HRLineHeightSpan`,现有 HRLineHeightSpanGlyphTest / HRLineHeightSpanTest) +- Android inline-code chip 几何(`KRRichTextViewDrawer` / `KRRichTextBuilder` —— 目前**缺** drawer 几何锁定测试,补测试是公开 backlog 项) +- compose lazy scroll echo / offset(`KuiklyScrollInfo` / `SubcomposeLayout`,两周 5+ 次迭代的区域) + +## 与 fork 特性的冲突处理 + +fork-only 特性(如 native dispatch capture 家族 3509beef/95275204、slock inline-code chrome)与 upstream 修复撞同一区域时,**禁止机械覆盖**:必须由特性作者联合审查后手工合并,并配真机回归门(参考 #1508 OHOS 半边的处理)。 diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..90b5a0df0 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "cocoapods", "1.16.2" +gem "concurrent-ruby", "1.3.4" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..85896e9c7 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,109 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + activesupport (6.1.7.10) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + claide (1.1.0) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.4) + drb (2.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.17.4) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.7.6) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + netrc (0.11.0) + nkf (0.3.0) + prism (1.9.0) + public_suffix (4.0.7) + rexml (3.4.4) + ruby-macho (2.5.1) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + zeitwerk (2.6.18) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods (= 1.16.2) + concurrent-ruby (= 1.3.4) + +BUNDLED WITH + 2.5.22 diff --git a/androidApp/src/main/java/com/tencent/kuikly/android/demo/KuiklyRenderActivity.kt b/androidApp/src/main/java/com/tencent/kuikly/android/demo/KuiklyRenderActivity.kt index b36f4a73d..da406e991 100644 --- a/androidApp/src/main/java/com/tencent/kuikly/android/demo/KuiklyRenderActivity.kt +++ b/androidApp/src/main/java/com/tencent/kuikly/android/demo/KuiklyRenderActivity.kt @@ -84,6 +84,11 @@ class KuiklyRenderActivity : AppCompatActivity() { hrContainerView = findViewById(R.id.hr_container) loadingView = findViewById(R.id.hr_loading) errorView = findViewById(R.id.hr_error) + // 横竖屏 Demo 转屏间隙需要宿主容器黑底兜底;其他页面保持默认,避免全局变黑。 + if (pageName == "ComposeVideoOrientationDemo" || pageName == "ComposeOrientationOverlayDemo") { + findViewById(android.R.id.content).setBackgroundColor(Color.BLACK) + hrContainerView.setBackgroundColor(Color.BLACK) + } // 4. 触发Kuikly View实例化 // hrContainerView:承载Kuikly的容器View // contextCode: jvm模式下传递"" diff --git a/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/KRTextPostProcessorAdapter.kt b/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/KRTextPostProcessorAdapter.kt index 214363ede..0705a8df8 100644 --- a/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/KRTextPostProcessorAdapter.kt +++ b/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/KRTextPostProcessorAdapter.kt @@ -16,7 +16,11 @@ package com.tencent.kuikly.android.demo.adapter import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect import android.graphics.drawable.Drawable +import android.os.Build import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.DynamicDrawableSpan @@ -89,7 +93,12 @@ class KRTextPostProcessorAdapter(context: Context) : IKRTextPostProcessorAdapter drawable?.setBounds(0, 0, emojiSize, emojiSize) if (drawable != null) { spannable.setSpan( - ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER), + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + // Android 10 (API 29) 上系统 ImageSpan 换行时基线计算有 bug,使用自定义实现绕过 + CenterAlignedImageSpan(drawable) + } else { + ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER) + }, match.range.first, match.range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE @@ -101,6 +110,66 @@ class KRTextPostProcessorAdapter(context: Context) : IKRTextPostProcessorAdapter return TextPostProcessorOutput(spannable) } + /** + * Android 10 (API 29) 专用:修复系统 [ImageSpan] 在换行时基线计算错误导致的错位问题。 + * + * 系统 bug 原因:Android 10 上 [DynamicDrawableSpan] 的 [getSize] 方法对行高的计算 + * 与文本实际行高不一致,导致跨行时图片垂直位置偏移。 + * + * 解决方案:手动计算图片在所在行的垂直居中位置,精确控制绘制。 + */ + private class CenterAlignedImageSpan(drawable: Drawable) : ImageSpan(drawable) { + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt?, + ): Int { + val drawable = drawable + val rect: Rect = drawable.bounds + + // 计算行高并设置 FontMetricsInt,确保图片在行内垂直居中 + fm?.let { + val fontHeight = paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent + val drHeight = rect.height() + val top = fontHeight / 2 - drHeight / 2 + val bottom = fontHeight / 2 + drHeight / 2 + + it.ascent = -bottom + it.top = -bottom + it.bottom = top + it.descent = top + } + + return rect.right + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint, + ) { + val drawable = drawable + canvas.save() + + // 计算垂直居中偏移 + val fontMetrics = paint.fontMetricsInt + val transY = y + fontMetrics.ascent + (fontMetrics.descent - fontMetrics.ascent) / 2 - drawable.bounds.height() / 2 + + canvas.translate(x, transY.toFloat()) + drawable.draw(canvas) + canvas.restore() + } + } + // 保留旧方法以兼容接口 @Deprecated("Use onTextPostProcess(kuiklyRenderContext, inputParams) instead") override fun onTextPostProcess(inputParams: TextPostProcessorInput): TextPostProcessorOutput { diff --git a/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/VideoViewAdapter.kt b/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/VideoViewAdapter.kt index 90313d292..c68c71f87 100644 --- a/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/VideoViewAdapter.kt +++ b/androidApp/src/main/java/com/tencent/kuikly/android/demo/adapter/VideoViewAdapter.kt @@ -19,6 +19,7 @@ import android.content.Context import android.net.Uri import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.PlaybackParameters import com.google.android.exoplayer2.Player.Listener @@ -52,6 +53,7 @@ class KuiklyVideoView(context: Context, private val src: String, private val lis useController = false val item = MediaItem.fromUri(Uri.parse(src)) exoPlayer.addMediaItem(item) + exoPlayer.repeatMode = Player.REPEAT_MODE_ONE exoPlayer.prepare() player = exoPlayer diff --git a/androidApp/src/main/java/com/tencent/kuikly/android/demo/module/KRBridgeModule.kt b/androidApp/src/main/java/com/tencent/kuikly/android/demo/module/KRBridgeModule.kt index 6cd613f2d..d3ef5c4bc 100644 --- a/androidApp/src/main/java/com/tencent/kuikly/android/demo/module/KRBridgeModule.kt +++ b/androidApp/src/main/java/com/tencent/kuikly/android/demo/module/KRBridgeModule.kt @@ -18,6 +18,7 @@ package com.tencent.kuikly.android.demo.module import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.pm.ActivityInfo import android.graphics.drawable.Drawable import android.util.Log import android.widget.Toast @@ -74,6 +75,9 @@ class KRBridgeModule : KuiklyRenderBaseModule() { "toast" -> { toast(params) } + "requestOrientation" -> { + requestOrientation(params) + } "log" -> { log(params) } @@ -130,6 +134,15 @@ class KRBridgeModule : KuiklyRenderBaseModule() { Toast.LENGTH_SHORT).show() } + private fun requestOrientation(params: String?) { + val target = JSONObject(params ?: "{}").optString("orientation") + activity?.requestedOrientation = when (target) { + "landscape" -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + "portrait" -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + private fun copyToPasteboard(params: String?) { if (params == null) { return diff --git a/compose/build.2.0.ohos.gradle.kts b/compose/build.2.0.ohos.gradle.kts index 986fb4ac0..4b12e34c3 100644 --- a/compose/build.2.0.ohos.gradle.kts +++ b/compose/build.2.0.ohos.gradle.kts @@ -57,11 +57,11 @@ 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("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-KBA-001") + 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") } @@ -109,4 +109,4 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } -} \ No newline at end of file +} diff --git a/compose/build.2.1.21.gradle.kts b/compose/build.2.1.21.gradle.kts index 21e00c118..daae86f61 100644 --- a/compose/build.2.1.21.gradle.kts +++ b/compose/build.2.1.21.gradle.kts @@ -80,7 +80,8 @@ kotlin { } commonTest.dependencies { -// implementation(libs.kotlin.test) + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } // Android 特有源集中添加 ProfileInstaller 依赖 @@ -91,6 +92,12 @@ kotlin { // 保留现有依赖... } } + + val androidUnitTest by getting { + dependencies { + implementation("org.robolectric:robolectric:4.12.2") + } + } } } @@ -132,4 +139,4 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } -} \ No newline at end of file +} diff --git a/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.android.kt b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.android.kt index 10d600400..b3b6db560 100644 --- a/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.android.kt +++ b/compose/src/androidMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.android.kt @@ -30,7 +30,13 @@ internal actual inline fun platformScheduleOnKuiklyThread(pagerId: String) { } } +internal actual inline fun platformScheduleIdleOnKuiklyThread(pagerId: String) { + KuiklyRenderCoreContextScheduler.scheduleIdleTask { + KuiklyContextScheduler.runIdleTask(pagerId) + } +} + internal actual inline fun platformNotifyKuiklyException(t: Throwable) { // todo support notify exception BridgeManager.callExceptionMethod(t.stackTraceToString()) -} \ No newline at end of file +} 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/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/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryConcurrencyTest.kt b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryConcurrencyTest.kt new file mode 100644 index 000000000..391d5823e --- /dev/null +++ b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryConcurrencyTest.kt @@ -0,0 +1,154 @@ +/* + * 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.profiler + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProfilerCompositionStateRegistryConcurrencyTest { + + @Test + fun twoCompositionsOnRealThreadsNeverBorrowEachOthersScope() { + val registry = ProfilerCompositionStateRegistry() + val bothScopesActive = CyclicBarrier(2) + val compositionBEnded = CyclicBarrier(2) + val failure = AtomicReference(null) + + val threadA = checkedThread("profiler-composition-a", failure) { + val pass = registry.beginComposition("composition-a", mapOf("scope-a" to emptySet())) + assertTrue(registry.registerHandle("composition-a", pass.generation, "handle-a")) + registry.beginScope("composition-a", pass.generation, "scope-a") + bothScopesActive.await(10, TimeUnit.SECONDS) + + repeat(10_000) { + val snapshot = registry.currentScopeSnapshot() + assertTrue(snapshot.hasPreciseMapping) + assertEquals("scope-a", snapshot.scope) + } + + compositionBEnded.await(10, TimeUnit.SECONDS) + repeat(1_000) { + assertEquals("scope-a", registry.currentScopeSnapshot().scope) + } + registry.endScope("composition-a", pass.generation, "scope-a") + assertEquals(listOf("handle-a"), registry.endComposition("composition-a")) + } + + val threadB = checkedThread("profiler-composition-b", failure) { + val pass = registry.beginComposition("composition-b", mapOf("scope-b" to emptySet())) + assertTrue(registry.registerHandle("composition-b", pass.generation, "handle-b")) + registry.beginScope("composition-b", pass.generation, "scope-b") + bothScopesActive.await(10, TimeUnit.SECONDS) + + repeat(10_000) { + val snapshot = registry.currentScopeSnapshot() + assertTrue(snapshot.hasPreciseMapping) + assertEquals("scope-b", snapshot.scope) + } + registry.endScope("composition-b", pass.generation, "scope-b") + assertEquals(listOf("handle-b"), registry.endComposition("composition-b")) + compositionBEnded.await(10, TimeUnit.SECONDS) + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + + joinChecked(threadA, threadB, failure) + } + + @Test + fun concurrentRapidLifecycleAndProfilerDisposeNeverLeakForeignScope() { + val registry = ProfilerCompositionStateRegistry() + val start = CountDownLatch(1) + val failure = AtomicReference(null) + + val threadA = checkedThread("profiler-rapid-a", failure) { + start.await(10, TimeUnit.SECONDS) + runRapidLifecycle(registry, "composition-a", "scope-a") + } + val threadB = checkedThread("profiler-rapid-b", failure) { + start.await(10, TimeUnit.SECONDS) + runRapidLifecycle(registry, "composition-b", "scope-b") + } + + start.countDown() + joinChecked(threadA, threadB, failure) + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + + private fun runRapidLifecycle( + registry: ProfilerCompositionStateRegistry, + composition: String, + scope: String + ) { + repeat(5_000) { iteration -> + val first = registry.beginComposition(composition, mapOf(scope to emptySet())) + registry.beginScope(composition, first.generation, scope) + + if (iteration % 7 == 0) { + val restarted = registry.beginComposition(composition, mapOf(scope to emptySet())) + registry.endScope(composition, first.generation, scope) + registry.scopeDisposed(composition, first.generation, scope) + registry.beginScope(composition, restarted.generation, scope) + } + + val snapshot = registry.currentScopeSnapshot() + assertTrue(snapshot.scope == null || snapshot.scope == scope) + if (snapshot.scope != null) { + assertTrue(snapshot.hasPreciseMapping) + } + + if (iteration % 31 == 0) { + registry.disposeAll() + } else { + registry.endComposition(composition) + } + val afterEnd = registry.currentScopeSnapshot() + assertNull(afterEnd.scope) + assertFalse(afterEnd.hasPreciseMapping) + } + } + + private fun checkedThread( + name: String, + failure: AtomicReference, + block: () -> Unit + ): Thread = thread(start = true, name = name) { + try { + block() + } catch (throwable: Throwable) { + failure.compareAndSet(null, throwable) + } + } + + private fun joinChecked( + first: Thread, + second: Thread, + failure: AtomicReference + ) { + first.join(TimeUnit.SECONDS.toMillis(30)) + second.join(TimeUnit.SECONDS.toMillis(30)) + assertFalse(first.isAlive, "${first.name} did not finish") + assertFalse(second.isAlive, "${second.name} did not finish") + failure.get()?.let { throw it } + } +} diff --git a/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTrackerConcurrencyTest.kt b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTrackerConcurrencyTest.kt new file mode 100644 index 000000000..ead181fde --- /dev/null +++ b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/profiler/RecompositionTrackerConcurrencyTest.kt @@ -0,0 +1,205 @@ +/* + * 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.profiler + +import androidx.compose.runtime.InternalComposeTracingApi +import java.util.concurrent.CountDownLatch +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(InternalComposeTracingApi::class) +class RecompositionTrackerConcurrencyTest { + + @Test + fun interleavedTracerEndsPopOnlyTheCallingThreadsEntries() { + val tracker = startedTracker() + val aStarted = CountDownLatch(1) + val bStarted = CountDownLatch(1) + val allowBEnd = CountDownLatch(1) + val failure = AtomicReference(null) + + val threadA = checkedThread("tracer-owner-a", failure) { + tracker.compositionTracer.traceEventStart(1, 0, 0, "AParent (A.kt:1)") + tracker.compositionTracer.traceEventStart(2, 0, 0, "AChild (A.kt:2)") + aStarted.countDown() + assertTrue(bStarted.await(10, TimeUnit.SECONDS)) + try { + tracker.compositionTracer.traceEventEnd() + tracker.compositionTracer.traceEventEnd() + + assertEquals( + setOf("AParent", "AChild"), + tracker.generateReport().composables.map { it.name }.toSet() + ) + } finally { + allowBEnd.countDown() + } + } + + val threadB = checkedThread("tracer-owner-b", failure) { + assertTrue(aStarted.await(10, TimeUnit.SECONDS)) + tracker.compositionTracer.traceEventStart(3, 0, 0, "BParent (B.kt:1)") + tracker.compositionTracer.traceEventStart(4, 0, 0, "BChild (B.kt:2)") + bStarted.countDown() + assertTrue(allowBEnd.await(10, TimeUnit.SECONDS)) + tracker.compositionTracer.traceEventEnd() + tracker.compositionTracer.traceEventEnd() + } + + joinChecked(threadA, threadB, failure) + assertEquals( + setOf("AParent", "AChild", "BParent", "BChild"), + tracker.generateReport().composables.map { it.name }.toSet() + ) + tracker.stop() + } + + @Test + fun overlayDepthOnOneThreadDoesNotFilterAnotherThreadsBusinessTrace() { + val tracker = startedTracker() + val overlayStarted = CountDownLatch(1) + val businessFinished = CountDownLatch(1) + val failure = AtomicReference(null) + + val overlayThread = checkedThread("tracer-overlay", failure) { + tracker.compositionTracer.traceEventStart( + 1, + 0, + 0, + "com.tencent.kuikly.compose.profiler.ProfilerOverlaySlot (Overlay.kt:1)" + ) + overlayStarted.countDown() + assertTrue(businessFinished.await(10, TimeUnit.SECONDS)) + tracker.compositionTracer.traceEventEnd() + } + + val businessThread = checkedThread("tracer-business", failure) { + assertTrue(overlayStarted.await(10, TimeUnit.SECONDS)) + tracker.compositionTracer.traceEventStart(2, 0, 0, "BusinessTrace (Business.kt:1)") + tracker.compositionTracer.traceEventEnd() + businessFinished.countDown() + } + + joinChecked(overlayThread, businessThread, failure) + assertEquals( + listOf("BusinessTrace"), + tracker.generateReport().composables.map { it.name } + ) + tracker.stop() + } + + @Test + fun stopClearsEveryThreadBucketBeforeTrackerRestart() { + val tracker = RecompositionTracker() + val collector = CollectingStrategy() + tracker.addOutputStrategy(collector) + tracker.start(testConfig()) + assertTrue(tracker.onFrameStart()) + + val staleEntriesReady = CyclicBarrier(3) + val trackerRestarted = CyclicBarrier(3) + val failure = AtomicReference(null) + + val threadA = checkedThread("tracer-restart-a", failure) { + tracker.compositionTracer.traceEventStart(1, 0, 0, "StaleParent (Stale.kt:1)") + staleEntriesReady.await(10, TimeUnit.SECONDS) + trackerRestarted.await(10, TimeUnit.SECONDS) + tracker.compositionTracer.traceEventStart(2, 0, 0, "FreshA (Fresh.kt:1)") + tracker.compositionTracer.traceEventEnd() + } + + val threadB = checkedThread("tracer-restart-b", failure) { + tracker.compositionTracer.traceEventStart( + 3, + 0, + 0, + "com.tencent.kuikly.compose.profiler.StaleOverlay (Stale.kt:2)" + ) + staleEntriesReady.await(10, TimeUnit.SECONDS) + trackerRestarted.await(10, TimeUnit.SECONDS) + tracker.compositionTracer.traceEventStart(4, 0, 0, "FreshB (Fresh.kt:2)") + tracker.compositionTracer.traceEventEnd() + } + + staleEntriesReady.await(10, TimeUnit.SECONDS) + tracker.stop() + // A callback already holding a reference to the old tracer must not repopulate the + // stopped tracker's cleared bucket before a restart. + tracker.compositionTracer.traceEventStart(99, 0, 0, "LateAfterStop (Late.kt:1)") + tracker.start(testConfig()) + assertTrue(tracker.onFrameStart()) + trackerRestarted.await(10, TimeUnit.SECONDS) + + joinChecked(threadA, threadB, failure) + tracker.compositionTracer.traceEventEnd() + tracker.onFrameEnd(0) + val freshEvents = collector.events.filterIsInstance() + assertEquals(setOf("FreshA", "FreshB"), freshEvents.map { it.composableName }.toSet()) + assertTrue(freshEvents.all { it.parentName == null }) + tracker.stop() + } + + private fun startedTracker(): RecompositionTracker = RecompositionTracker().also { tracker -> + tracker.start(testConfig()) + assertTrue(tracker.onFrameStart()) + } + + private fun testConfig(): RecompositionConfig = RecompositionConfig( + enableStateTracking = false, + includeFrameworkComposables = true, + enableLog = false, + enableFile = false, + enableBuiltinFilters = false + ) + + private fun checkedThread( + name: String, + failure: AtomicReference, + block: () -> Unit + ): Thread = thread(start = true, name = name) { + try { + block() + } catch (throwable: Throwable) { + failure.compareAndSet(null, throwable) + } + } + + private fun joinChecked( + first: Thread, + second: Thread, + failure: AtomicReference + ) { + first.join(TimeUnit.SECONDS.toMillis(30)) + second.join(TimeUnit.SECONDS.toMillis(30)) + assertFalse(first.isAlive, "${first.name} did not finish") + assertFalse(second.isAlive, "${second.name} did not finish") + failure.get()?.let { throw it } + } + + private class CollectingStrategy : RecompositionOutputStrategy { + val events = mutableListOf() + + override fun onFrameComplete(events: List) { + this.events += events + } + } +} diff --git a/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeSlotDrawReactivationTest.kt b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeSlotDrawReactivationTest.kt new file mode 100644 index 000000000..13690e9f7 --- /dev/null +++ b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeSlotDrawReactivationTest.kt @@ -0,0 +1,243 @@ +/* + * 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.layout + +import androidx.compose.runtime.Recomposer +import com.tencent.kuikly.compose.layout.hideOffsetScreenView +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.node.KNode +import com.tencent.kuikly.compose.ui.node.LayoutNode +import com.tencent.kuikly.compose.ui.node.Owner +import com.tencent.kuikly.compose.ui.node.OwnerSnapshotObserver +import com.tencent.kuikly.compose.ui.node.OwnedLayer +import com.tencent.kuikly.compose.ui.node.RootForTest +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.base.ViewBuilder +import com.tencent.kuikly.core.manager.BridgeManager +import com.tencent.kuikly.core.manager.PagerManager +import com.tencent.kuikly.core.pager.Pager +import com.tencent.kuikly.core.views.DivView +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SubcomposeSlotDrawReactivationTest { + + @Test + fun retainedSlotActualReuseEntryWakesAncestorsForSameAndCompatibleKeysOnly() { + val pagerId = "subcompose-slot-draw-reactivation" + val pageName = "SubcomposeSlotDrawReactivationTest" + @Suppress("DEPRECATION") + val previousPageId = BridgeManager.currentPageId + @Suppress("DEPRECATION") + fun setCurrentPageId(value: String) { + BridgeManager.currentPageId = value + } + PagerManager.registerPageRouter(pageName) { + object : Pager() { + override fun body(): ViewBuilder = {} + } + } + setCurrentPageId(pagerId) + PagerManager.createPager(pagerId, pageName, "{}") + + val rootView = DivView().also { it.pagerId = pagerId } + val root = KNode>(rootView) + val owner = TestOwner(root) + root.attach(owner) + val recomposer = Recomposer(EmptyCoroutineContext) + val state = + LayoutNodeSubcompositionsState( + root = root, + slotReusePolicy = RetainAllCompatibleSlots + ).also { + it.compositionContext = recomposer + } + + try { + val firstHandle = state.precompose("message-a") {} + val retainedSlot = root.foldedChildren.single() as KNode<*> + val retainedLeaf = KNode>(DivView()) + retainedSlot.insertTopDown(0, retainedLeaf) + retainedSlot.insertAt(0, retainedLeaf) + assertEquals(pagerId, retainedLeaf.view.pagerId) + assertEquals(retainedSlot.view.nativeRef, retainedLeaf.view.parentRef) + assertSame( + retainedLeaf.view, + PagerManager.getPager(pagerId).getViewWithNativeRef(retainedLeaf.view.nativeRef) + ) + root.clearDrawInvalidationForTest() + retainedSlot.clearDrawInvalidationForTest() + retainedLeaf.clearDrawInvalidationForTest() + + firstHandle.dispose() + + assertEquals(true, retainedLeaf.viewVisible, "retention must hide and remember the native leaf") + root.clearDrawInvalidationForTest() + retainedSlot.clearDrawInvalidationForTest() + retainedLeaf.clearDrawInvalidationForTest() + retainedLeaf.invalidateDraw() + retainedSlot.clearDrawInvalidationForTest() + root.clearDrawInvalidationForTest() + + val sameKeyHandle = state.precompose("message-a") {} + + assertSame(retainedSlot, root.foldedChildren.single()) + assertTrue(retainedLeaf.isDrawInvalidatedForTest()) + assertTrue(retainedSlot.isDrawInvalidatedForTest()) + assertTrue(root.isDrawInvalidatedForTest()) + + // An already-active/precomposed key does not pass through takeNodeFromReusables and + // must therefore leave a clean draw tree untouched. + root.clearDrawInvalidationForTest() + retainedSlot.clearDrawInvalidationForTest() + retainedLeaf.clearDrawInvalidationForTest() + state.precompose("message-a") {} + assertFalse(retainedSlot.isDrawInvalidatedForTest()) + assertFalse(root.isDrawInvalidatedForTest()) + + sameKeyHandle.dispose() + root.clearDrawInvalidationForTest() + retainedSlot.clearDrawInvalidationForTest() + retainedLeaf.clearDrawInvalidationForTest() + retainedLeaf.invalidateDraw() + retainedSlot.clearDrawInvalidationForTest() + root.clearDrawInvalidationForTest() + + state.precompose("message-b") {} + + assertSame(retainedSlot, root.foldedChildren.single()) + assertTrue(retainedSlot.isDrawInvalidatedForTest()) + assertTrue(root.isDrawInvalidatedForTest()) + + // A precomposed slot can be hidden offscreen and then drawn completely clean while it + // is still unplaced. Consuming that exact key wakes the slot ancestry, but placement + // must also dirty the clean descendant whose native visibility prop is restored; + // otherwise Compose reports the item placed while the native render tree stays blank. + retainedSlot.hideOffsetScreenView() + assertEquals(true, retainedLeaf.viewVisible) + retainedSlot.clearDrawInvalidationForTest() + retainedLeaf.clearDrawInvalidationForTest() + root.clearDrawInvalidationForTest() + retainedLeaf.measurePolicy = MeasurePolicy { _, _ -> layout(1, 1) {} } + root.measurePolicy = state.createMeasurePolicy { + val placeable = subcompose("message-b") {}.single().measure(Constraints.fixed(1, 1)) + layout(1, 1) { placeable.place(0, 0) } + } + assertTrue(root.remeasure(Constraints.fixed(1, 1))) + + assertSame(retainedSlot, root.foldedChildren.single()) + assertFalse(retainedLeaf.isDrawInvalidatedForTest()) + assertTrue(retainedSlot.isDrawInvalidatedForTest()) + assertTrue(root.isDrawInvalidatedForTest()) + + // Execute the real placement path. NodeCoordinator.placeSelf calls + // KNode.updateKuiklyViewFrame, which owns the production visibility restore. + root.place(0, 0) + + assertNull(retainedLeaf.viewVisible) + assertTrue(retainedLeaf.isDrawInvalidatedForTest()) + assertTrue(retainedSlot.isDrawInvalidatedForTest()) + assertTrue(root.isDrawInvalidatedForTest()) + } finally { + recomposer.close() + PagerManager.destroyPager(pagerId) + setCurrentPageId(previousPageId) + } + } + + private object RetainAllCompatibleSlots : SubcomposeSlotReusePolicy { + override fun getSlotsToRetain(slotIds: SubcomposeSlotReusePolicy.SlotIdsSet) = Unit + + override fun areCompatible(slotId: Any?, reusableSlotId: Any?): Boolean = true + } + + private class TestOwner( + override val root: KNode> + ) : Owner { + override val sharedDrawScope: com.tencent.kuikly.compose.ui.node.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 + } +} diff --git a/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeViewportShrinkPendingOffsetTest.kt b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeViewportShrinkPendingOffsetTest.kt new file mode 100644 index 000000000..b0e7b893f --- /dev/null +++ b/compose/src/androidUnitTest/kotlin/com/tencent/kuikly/compose/ui/node/KNodeViewportShrinkPendingOffsetTest.kt @@ -0,0 +1,362 @@ +/* + * 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. + */ + +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.layout.MeasurePolicy +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.IntOffset +import com.tencent.kuikly.compose.ui.unit.LayoutDirection +import com.tencent.kuikly.core.base.DeclarativeBaseView +import com.tencent.kuikly.core.base.ViewBuilder +import com.tencent.kuikly.core.manager.BridgeManager +import com.tencent.kuikly.core.manager.PagerManager +import com.tencent.kuikly.core.pager.Pager +import com.tencent.kuikly.core.views.ScrollParams +import com.tencent.kuikly.core.views.ScrollerAttr +import com.tencent.kuikly.core.views.ScrollerEvent +import com.tencent.kuikly.core.views.ScrollerView +import com.tencent.kuikly.core.views.SpringAnimation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.math.max +import kotlin.math.min +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class KNodeViewportShrinkPendingOffsetTest { + + @Test + fun shrinkReplaysPendingProgrammaticOffsetAfterFrameCommitBeforePixelsAreSampled() { + withViewportFixture(pagerId = "viewport-shrink-pending-offset") { + val density = scrollInfo.getDensity() + assertEquals(622f / density, scrollerView.renderView?.currentFrame?.height) + + val nativeOffsetPx = 4_000 + val pendingTargetPx = 5_000 + setPendingProgrammaticOffset( + nativeOffsetPx = nativeOffsetPx, + pendingTargetPx = pendingTargetPx, + ) + + resizeViewportTo(315) + + assertEquals( + listOf(PendingOffsetReplay(pendingTargetPx, 315)), + scrollerView.offsetReplays, + "the pending target must be replayed only after the shrunken native frame is committed", + ) + val nonWhiteCount = visiblePixelCount( + viewportStart = scrollerView.appliedNativeOffsetPx, + viewportSize = 315, + placedRowStart = pendingTargetPx, + placedRowSize = 100, + rowWidth = 300, + ) + assertTrue(nonWhiteCount > 0, "shrink frame must retain at least one drawable row pixel") + } + } + + @Test + fun shrinkDoesNotResurrectPendingTargetConsumedDuringFrameCommit() { + withViewportFixture(pagerId = "viewport-shrink-consumed-offset") { + val pendingTargetPx = 5_000 + setPendingProgrammaticOffset( + nativeOffsetPx = 4_000, + pendingTargetPx = pendingTargetPx, + ) + scrollerView.onFrameCommitted = { + scrollerView.appliedNativeOffsetPx = pendingTargetPx + scrollInfo.ignoreScrollOffset = null + } + + resizeViewportTo(315) + + assertEquals(315, scrollerView.committedFrameHeightPx()) + assertNull(scrollInfo.ignoreScrollOffset) + assertTrue( + scrollerView.offsetReplays.isEmpty(), + "a synchronously consumed target must not be replayed after frame commit", + ) + } + } + + @Test + fun shrinkDoesNotOverwriteNewPendingOwnerCreatedDuringFrameCommit() { + withViewportFixture(pagerId = "viewport-shrink-newer-offset") { + setPendingProgrammaticOffset( + nativeOffsetPx = 4_000, + pendingTargetPx = 5_000, + ) + val newerTarget = IntOffset(0, 5_200) + scrollerView.onFrameCommitted = { + scrollInfo.composeOffset = newerTarget.y.toFloat() + scrollInfo.ignoreScrollOffset = newerTarget + } + + resizeViewportTo(315) + + assertEquals(newerTarget, scrollInfo.ignoreScrollOffset) + assertEquals(newerTarget.y.toFloat(), scrollInfo.composeOffset) + assertTrue( + scrollerView.offsetReplays.isEmpty(), + "the captured target must not overwrite a newer programmatic owner", + ) + } + } + + @Test + fun expansionDoesNotReplayPendingTargetThroughShrinkOnlyPath() { + withViewportFixture( + pagerId = "viewport-expansion-pending-offset", + initialViewportHeight = 315, + ) { + val pendingTarget = IntOffset(0, 5_000) + setPendingProgrammaticOffset( + nativeOffsetPx = 4_000, + pendingTargetPx = pendingTarget.y, + ) + + resizeViewportTo(622) + + assertEquals(pendingTarget, scrollInfo.ignoreScrollOffset) + assertTrue( + scrollerView.offsetReplays.isEmpty(), + "viewport expansion must not use the shrink-only pending replay path", + ) + } + } + + private fun withViewportFixture( + pagerId: String, + initialViewportHeight: Int = 622, + block: ViewportFixture.() -> Unit, + ) { + val pageName = "KNodeViewportShrinkPendingOffsetTest" + @Suppress("DEPRECATION") + val previousPageId = BridgeManager.currentPageId + @Suppress("DEPRECATION") + fun setCurrentPageId(value: String) { + BridgeManager.currentPageId = value + } + PagerManager.registerPageRouter(pageName) { + object : Pager() { + override fun body(): ViewBuilder = {} + } + } + setCurrentPageId(pagerId) + PagerManager.createPager(pagerId, pageName, "{}") + + val rootView = PagerManager.getPager(pagerId) as Pager + val root = KNode>(rootView) + val scrollerView = RecordingScrollerView() + val scroller = KNode>(scrollerView) + val owner = TestOwner(root) + root.attach(owner) + root.insertTopDown(0, scroller) + root.insertAt(0, scroller) + + val scrollInfo = com.tencent.kuikly.compose.gestures.KuiklyScrollInfo().also { info -> + info.scrollView = scrollerView + info.currentContentSize = 6_000 + } + scrollerView.renderProperties = RenderProperties().also { properties -> + properties.kuiklyScrollInfo = scrollInfo + } + scroller.measurePolicy = MeasurePolicy { _, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) {} + } + + var viewportHeight = initialViewportHeight + root.measurePolicy = MeasurePolicy { measurables, constraints -> + val placeable = measurables.single().measure( + Constraints.fixed(constraints.maxWidth, viewportHeight) + ) + layout(constraints.maxWidth, viewportHeight) { + placeable.place(0, 0) + } + } + + try { + assertTrue(root.remeasure(Constraints.fixed(300, viewportHeight))) + root.place(0, 0) + ViewportFixture( + scrollerView = scrollerView, + scrollInfo = scrollInfo, + initialViewportHeight = viewportHeight, + resize = { newHeight -> + viewportHeight = newHeight + assertTrue(root.remeasure(Constraints.fixed(300, newHeight))) + root.place(0, 0) + }, + ).block() + } finally { + PagerManager.destroyPager(pagerId) + setCurrentPageId(previousPageId) + } + } + + private data class PendingOffsetReplay( + val offsetPx: Int, + val frameHeightPx: Int, + ) + + private class ViewportFixture( + val scrollerView: RecordingScrollerView, + val scrollInfo: com.tencent.kuikly.compose.gestures.KuiklyScrollInfo, + initialViewportHeight: Int, + private val resize: (Int) -> Unit, + ) { + private var viewportHeight = initialViewportHeight + + fun setPendingProgrammaticOffset( + nativeOffsetPx: Int, + pendingTargetPx: Int, + ) { + val density = scrollInfo.getDensity() + val scrollHandler = requireNotNull( + scrollerView.getViewEvent() + .handlerWithEventName(ScrollerEvent.ScrollerEventConst.SCROLL) + ) + scrollHandler( + ScrollParams( + offsetX = 0f, + offsetY = nativeOffsetPx / density, + contentWidth = 300f, + contentHeight = 6_000f / density, + viewWidth = 300f, + viewHeight = viewportHeight.toFloat(), + isDragging = false, + ) + ) + scrollInfo.composeOffset = pendingTargetPx.toFloat() + scrollInfo.ignoreScrollOffset = IntOffset(0, pendingTargetPx) + scrollerView.appliedNativeOffsetPx = nativeOffsetPx + scrollerView.offsetReplays.clear() + } + + fun resizeViewportTo(newHeight: Int) { + viewportHeight = newHeight + resize(newHeight) + } + } + + private class RecordingScrollerView : ScrollerView() { + var appliedNativeOffsetPx: Int = 0 + val offsetReplays = mutableListOf() + var onFrameCommitted: (() -> Unit)? = null + + override fun setFrameToRenderView(frame: com.tencent.kuikly.core.layout.Frame) { + super.setFrameToRenderView(frame) + onFrameCommitted?.invoke() + } + + fun committedFrameHeightPx(): Int { + val density = getPager().pagerDensity() + return ((renderView?.currentFrame?.height ?: 0f) * density).toInt() + } + + override fun callContentOffset( + offsetX: Float, + offsetY: Float, + animated: Boolean, + springAnimation: SpringAnimation?, + ) { + val density = getPager().pagerDensity() + val offsetPx = (offsetY * density).toInt() + appliedNativeOffsetPx = offsetPx + offsetReplays += PendingOffsetReplay( + offsetPx = offsetPx, + frameHeightPx = ((renderView?.currentFrame?.height ?: 0f) * density).toInt(), + ) + } + } + + private fun visiblePixelCount( + viewportStart: Int, + viewportSize: Int, + placedRowStart: Int, + placedRowSize: Int, + rowWidth: Int, + ): Int { + val visibleStart = max(viewportStart, placedRowStart) + val visibleEnd = min(viewportStart + viewportSize, placedRowStart + placedRowSize) + return (visibleEnd - visibleStart).coerceAtLeast(0) * rowWidth + } + + 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 + } +} diff --git a/compose/src/appleMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ios.kt b/compose/src/appleMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ios.kt index fa701f7c6..79c13dee2 100644 --- a/compose/src/appleMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ios.kt +++ b/compose/src/appleMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ios.kt @@ -18,6 +18,7 @@ package com.tencent.kuikly.compose.coroutines.internal import com.tencent.kuikly.com_tencent_kuikly_IsCurrentOnContextThread import com.tencent.kuikly.com_tencent_kuikly_ScheduleContextTask +import com.tencent.kuikly.com_tencent_kuikly_ScheduleContextIdleTask import com.tencent.kuikly.core.manager.BridgeManager import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointer @@ -38,6 +39,13 @@ internal actual inline fun platformScheduleOnKuiklyThread(pagerId: String) { }) } +internal actual inline fun platformScheduleIdleOnKuiklyThread(pagerId: String) { + com_tencent_kuikly_ScheduleContextIdleTask(pagerId, staticCFunction { pagerIdBytes: CPointer? -> + val idStr = pagerIdBytes?.toKString() ?: return@staticCFunction + KuiklyContextScheduler.runIdleTask(idStr) + }) +} + internal actual inline fun platformNotifyKuiklyException(t: Throwable) { BridgeManager.callExceptionMethod(t.stackTraceToString()) -} \ No newline at end of file +} 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..dbd9cee7c 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 = 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" + const val KEY_EVENT_META_PRESSED = "metaPressed" + const val KEY_EVENT_SHIFT_PRESSED = "shiftPressed" } override var ignoreLayout = true @@ -115,11 +134,26 @@ open class ComposeContainer : * Profiler 生命周期监听器,负责在 Profiler start 时把 FileModule 实例传入。 * 页面销毁时注销,避免内存泄漏。 */ + private var profilerFileModule: FileModule? = null + + private fun registerProfilerFileModule() { + val module = getModule(FileModule.MODULE_NAME) ?: return + val previous = profilerFileModule + if (previous !== module) { + previous?.let { RecompositionProfiler.unregisterFileModule(it) } + profilerFileModule = module + } + RecompositionProfiler.registerFileModule(module) + } + + private fun unregisterProfilerFileModule() { + profilerFileModule?.let { RecompositionProfiler.unregisterFileModule(it) } + profilerFileModule = null + } + private val fileModuleListener = object : RecompositionProfiler.ProfilerLifecycleListener { override fun onProfilerStarted(tracker: RecompositionTracker) { - getModule(FileModule.MODULE_NAME)?.let { - RecompositionProfiler.setFileModule(it) - } + registerProfilerFileModule() } override fun onProfilerStopped() { /* nothing */ } } @@ -164,9 +198,7 @@ open class ComposeContainer : // 如果 Profiler 已启用(start 先于页面创建),此时补传 FileModule if (RecompositionProfiler.isEnabled) { - getModule(FileModule.MODULE_NAME)?.let { - RecompositionProfiler.setFileModule(it) - } + registerProfilerFileModule() } } @@ -208,11 +240,13 @@ open class ComposeContainer : override fun pageWillDestroy() { super.pageWillDestroy() + // Native bridge 被销毁前先从进程级 Profiler 解绑,使待写文件可回退到其他 live Pager。 + unregisterProfilerFileModule() + RecompositionProfiler.removeLifecycleListener(fileModuleListener) stopFrameDispatcher() mediator?.updateAppState(false) dispose() updateLifecycleState(Lifecycle.State.DESTROYED) - RecompositionProfiler.removeLifecycleListener(fileModuleListener) } private fun updateLifecycleState(state: Lifecycle.State) { @@ -261,6 +295,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 +326,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 +425,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/coil3/AsyncImagePainter.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coil3/AsyncImagePainter.kt index 457f50cd8..1b50fa21d 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coil3/AsyncImagePainter.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coil3/AsyncImagePainter.kt @@ -5,11 +5,26 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import com.tencent.kuikly.compose.coil3.AsyncImagePainter.State +import com.tencent.kuikly.compose.ui.KuiklyPainter +import com.tencent.kuikly.compose.ui.graphics.ImageBitmap import com.tencent.kuikly.compose.ui.graphics.painter.Painter import com.tencent.kuikly.compose.ui.platform.LocalActivity -import com.tencent.kuikly.compose.ui.KuiklyPainter import kotlinx.coroutines.flow.StateFlow +/** + * Load [model] through the same shared image cache used by [rememberAsyncImagePainter] and expose + * the decoded bitmap to Canvas consumers. The returned bitmap can initially report a zero size; + * its observable cache status updates the composition when decoding completes. + */ +@Composable +fun rememberAsyncImageBitmap(model: String?): ImageBitmap? { + val source = model?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val activity = LocalActivity.current + return remember(activity, source) { + activity.imageCacheManager.loadImage(source) + } +} + /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. * @@ -155,4 +170,4 @@ abstract class AsyncImagePainter internal constructor() : Painter() { // val result: Unit, ) : State } -} \ No newline at end of file +} 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/coroutines/KuiklyIdleContext.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/KuiklyIdleContext.kt new file mode 100644 index 000000000..12b4b30da --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/KuiklyIdleContext.kt @@ -0,0 +1,25 @@ +/* + * 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. + */ +package com.tencent.kuikly.compose.coroutines + +import com.tencent.kuikly.compose.coroutines.internal.IdleComposeDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext + +/** + * Runs speculative work on the Kuikly context idle lane for [pagerId]. + * + * Every continuation of [block] is admitted only after normal context work has + * drained. New normal work invalidates a pending admission and runs first. + * Callers must keep each non-suspending section bounded: a callback that has + * already started cannot be preempted in the middle of arbitrary user code. + */ +suspend fun withKuiklyIdleContext( + pagerId: String, + block: suspend CoroutineScope.() -> T +): T = withContext(IdleComposeDispatcher(pagerId), block) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/IdleComposeDispatcher.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/IdleComposeDispatcher.kt new file mode 100644 index 000000000..6286dc11e --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/IdleComposeDispatcher.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. + */ +package com.tencent.kuikly.compose.coroutines.internal + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + +internal class IdleComposeDispatcher( + private val pagerId: String +) : CoroutineDispatcher() { + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = true + + override fun dispatch(context: CoroutineContext, block: Runnable) { + KuiklyContextScheduler.runOnKuiklyThreadIdle(pagerId) { cancel -> + if (cancel) { + context.cancel(CancellationException("The idle task was rejected, Pager($pagerId) is closed.")) + return@runOnKuiklyThreadIdle + } + block.run() + } + } + + override fun toString(): String = "IdleComposeDispatcher($pagerId)" + + override fun equals(other: Any?): Boolean = other is IdleComposeDispatcher && pagerId == other.pagerId + + override fun hashCode(): Int = pagerId.hashCode() +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.kt index 2f57a4409..df9f5025c 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.kt @@ -22,6 +22,11 @@ internal object KuiklyContextScheduler : SynchronizedObject() { private val taskMap = mutableMapOf Unit>>() private val scheduleMap = mutableMapOf() + private val idleTaskMap = mutableMapOf Unit>>() + private val idleScheduleMap = mutableMapOf() + private val normalGenerationMap = mutableMapOf() + private val idleScheduleGenerationMap = mutableMapOf() + private var executingIdlePagerId: String? = null init { platformInitScheduler() @@ -40,10 +45,15 @@ internal object KuiklyContextScheduler : SynchronizedObject() { * @param block 任务 */ fun runOnKuiklyThread(pagerId: String, block: (cancel: Boolean) -> Unit) { + if (executingIdlePagerId == pagerId && platformIsOnKuiklyThread(pagerId)) { + runOnKuiklyThreadIdle(pagerId, block) + return + } var needSchedule = false synchronized(this) { val taskList = taskMap[pagerId] ?: mutableListOf<(Boolean) -> Unit>().also { taskMap[pagerId] = it } taskList.add(block) + normalGenerationMap[pagerId] = (normalGenerationMap[pagerId] ?: 0L) + 1L if (scheduleMap[pagerId] != true) { scheduleMap[pagerId] = true needSchedule = true @@ -54,6 +64,23 @@ internal object KuiklyContextScheduler : SynchronizedObject() { } } + /** + * Enqueues speculative work on the Kuikly context idle lane. + * + * Idle work runs one callback at a time only after normal context work has + * drained. Normal work queued before the callback executes invalidates the + * idle admission and moves the callback behind the new foreground work. + * Work scheduled recursively from an idle callback inherits the idle lane. + */ + fun runOnKuiklyThreadIdle(pagerId: String, block: (cancel: Boolean) -> Unit) { + synchronized(this) { + val taskList = idleTaskMap[pagerId] + ?: mutableListOf<(Boolean) -> Unit>().also { idleTaskMap[pagerId] = it } + taskList.add(block) + } + scheduleIdleIfNeeded(pagerId) + } + /** * 执行任务,非线程安全 * @param pagerId 页面ID @@ -76,6 +103,77 @@ internal object KuiklyContextScheduler : SynchronizedObject() { platformNotifyKuiklyException(t) } } + scheduleIdleIfNeeded(pagerId) + } + + /** Executes at most one idle callback. Called by the platform idle lane. */ + internal fun runIdleTask(pagerId: String) { + val cancel = !BridgeManager.containNativeBridge(pagerId) + var task: ((Boolean) -> Unit)? = null + var shouldReschedule = false + synchronized(this) { + val scheduledGeneration = idleScheduleGenerationMap.remove(pagerId) + idleScheduleMap[pagerId] = false + val currentGeneration = normalGenerationMap[pagerId] ?: 0L + val hasNormalWork = scheduleMap[pagerId] == true || taskMap[pagerId].isNullOrEmpty().not() + val idleTasks = idleTaskMap[pagerId] + when ( + kuiklyIdleAdmissionDecision( + hasIdleWork = idleTasks.isNullOrEmpty().not(), + hasNormalWork = hasNormalWork, + scheduledGeneration = scheduledGeneration, + currentGeneration = currentGeneration + ) + ) { + KuiklyIdleAdmissionDecision.Run -> { + val admittedTasks = idleTasks ?: return@synchronized + task = admittedTasks.removeAt(0) + if (admittedTasks.isEmpty()) { + idleTaskMap.remove(pagerId) + } + } + KuiklyIdleAdmissionDecision.Reschedule -> shouldReschedule = true + KuiklyIdleAdmissionDecision.WaitForNormalDrain, + KuiklyIdleAdmissionDecision.None -> Unit + } + } + if (shouldReschedule) { + scheduleIdleIfNeeded(pagerId) + return + } + val idleTask = task ?: return + BridgeManager.currentPageId = pagerId + executingIdlePagerId = pagerId + try { + idleTask(cancel) + } catch (t: Throwable) { + platformNotifyKuiklyException(t) + } finally { + executingIdlePagerId = null + } + scheduleIdleIfNeeded(pagerId) + } + + private fun scheduleIdleIfNeeded(pagerId: String) { + var needSchedule = false + synchronized(this) { + val hasIdleWork = idleTaskMap[pagerId].isNullOrEmpty().not() + val hasNormalWork = scheduleMap[pagerId] == true || taskMap[pagerId].isNullOrEmpty().not() + if ( + shouldScheduleKuiklyIdle( + hasIdleWork = hasIdleWork, + hasNormalWork = hasNormalWork, + alreadyScheduled = idleScheduleMap[pagerId] == true + ) + ) { + idleScheduleMap[pagerId] = true + idleScheduleGenerationMap[pagerId] = normalGenerationMap[pagerId] ?: 0L + needSchedule = true + } + } + if (needSchedule) { + platformScheduleIdleOnKuiklyThread(pagerId) + } } } @@ -86,4 +184,6 @@ internal expect inline fun platformIsOnKuiklyThread(pagerId: String): Boolean internal expect inline fun platformScheduleOnKuiklyThread(pagerId: String) +internal expect inline fun platformScheduleIdleOnKuiklyThread(pagerId: String) + internal expect inline fun platformNotifyKuiklyException(t: Throwable) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmission.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmission.kt new file mode 100644 index 000000000..3e7d0e7f4 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmission.kt @@ -0,0 +1,34 @@ +/* + * 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. + */ +package com.tencent.kuikly.compose.coroutines.internal + +internal enum class KuiklyIdleAdmissionDecision { + Run, + Reschedule, + WaitForNormalDrain, + None +} + +internal fun kuiklyIdleAdmissionDecision( + hasIdleWork: Boolean, + hasNormalWork: Boolean, + scheduledGeneration: Long?, + currentGeneration: Long +): KuiklyIdleAdmissionDecision = + when { + !hasIdleWork -> KuiklyIdleAdmissionDecision.None + hasNormalWork -> KuiklyIdleAdmissionDecision.WaitForNormalDrain + scheduledGeneration != currentGeneration -> KuiklyIdleAdmissionDecision.Reschedule + else -> KuiklyIdleAdmissionDecision.Run + } + +internal fun shouldScheduleKuiklyIdle( + hasIdleWork: Boolean, + hasNormalWork: Boolean, + alreadyScheduled: Boolean +): Boolean = hasIdleWork && !hasNormalWork && !alreadyScheduled diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/diagnostics/LazyLayoutTrace.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/diagnostics/LazyLayoutTrace.kt new file mode 100644 index 000000000..8b9f0770a --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/diagnostics/LazyLayoutTrace.kt @@ -0,0 +1,210 @@ +package com.tencent.kuikly.compose.diagnostics + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Generic, switchable trace for lazy-layout measure and placement. + * + * Deliberately free of any product vocabulary: this library records what a + * measure pass did, never what the content means. Correlation values arriving + * from a host application are opaque — compared for equality so records can be + * joined, never parsed, persisted, or interpreted. + * + * Why it exists: a consumer reading `LazyListState.layoutInfo` only sees a + * snapshot after the fact, so it cannot tell which measure or placement first + * diverged from what the user was shown. That question can only be answered + * from inside the measure pass. + * + * The switch is a compile-time constant so a disabled build can be shown to do + * no work at all, rather than merely to stay quiet. Nothing here allocates, + * formats, or traverses when it is false. + */ +public object LazyLayoutTraceConfig { + /** + * Single build-time source of truth. A host that enables tracing must flip + * this and its own constant together; a build where the two disagree is + * rejected by [requireConsistentWith] rather than producing records that + * cover only one side of the boundary. + */ + // Forensics branch: enabled. Main keeps this false — that is the single + // line a build flips. Left false here, the inline guard returns before the + // lambda and the build produces no records at all, which is exactly the + // "compiles green, traces nothing" package this branch exists to avoid. + public const val ENABLED: Boolean = true + + /** + * Fails fast when the host's switch and this library's switch disagree. + * Such a build yields a chain that is missing one layer while appearing + * healthy, which is worse than no tracing at all. + */ + public fun requireConsistentWith(hostEnabled: Boolean) { + if (hostEnabled != ENABLED) { + error( + "lazy layout trace switch mismatch: host=$hostEnabled library=$ENABLED — " + + "both sides must be built from the same source of truth" + ) + } + } +} + +/** + * Opaque correlation values supplied by the host. + * + * [targetToken] identifies the item the host cares about without disclosing + * what it is; this library only ever compares it. + */ +public data class LazyTraceContext( + public val traceSession: String, + public val cycle: Long, + public val layoutGeneration: Long, + public val targetToken: String? +) + +/** Frame identity, required by every record that claims a moment. */ +public data class LazyTraceFrame( + public val frameSequence: Long, + public val frameTimeNanos: Long +) + +/** Where inside the library a record was produced. */ +public enum class LazyTraceStage { + MeasureStart, + MeasureResult, + Placement, + NativeCommit +} + +/** + * One measure or placement observation. Fields are the geometry a caller needs + * to tell "laid out" from "visible" — a distinction that index ranges alone + * cannot express. + */ +public class LazyTraceWiringError(message: String) : IllegalStateException(message) + +public data class LazyTraceMeasureRecord( + public val stage: LazyTraceStage, + public val function: String, + public val frame: LazyTraceFrame, + public val constraintsMaxMainAxis: Int, + public val viewportStartPx: Int, + public val viewportEndPx: Int, + public val scrollToBeConsumed: Float, + public val firstVisibleIndex: Int, + public val firstVisibleScrollOffset: Int, + public val visibleItemCount: Int, + public val totalItemCount: Int, + public val targetIndex: Int, + public val targetOffsetPx: Int, + public val targetSizePx: Int, + public val coveredPx: Int, + public val gapPx: Int +) + +/** Emission seam owned by the host; released with the composition that made it. */ +public fun interface LazyTraceSink { + public fun onMeasureRecord(context: LazyTraceContext, record: LazyTraceMeasureRecord) +} + +/** + * Host-injected trace handle. Held for the lifetime of one lazy layout and + * dropped with it — this library never keeps a process-wide sink, so it cannot + * become a second owner of anything. + */ +public class LazyLayoutTrace( + private val context: LazyTraceContext, + private val sink: LazyTraceSink +) { + /** + * Records a measure observation. The record is built inside the lambda so + * that with tracing disabled the geometry is never gathered — the check + * happens before any field is computed, which matters because this sits in + * the measure hot path. + */ + public inline fun measure(build: () -> LazyTraceMeasureRecord) { + if (!LazyLayoutTraceConfig.ENABLED) return + emit(build()) + } + + public fun emit(record: LazyTraceMeasureRecord) { + if (!LazyLayoutTraceConfig.ENABLED) return + sink.onMeasureRecord(context, record) + } + + /** + * The host's opaque item token, for equality against a layout item's key. + * Exposed rather than the whole context so a caller cannot start reading + * correlation values as if they carried meaning. + */ + public fun targetTokenOrNull(): String? = context.targetToken +} + +/** + * Covered/uncovered split of a viewport, unioning clipped item spans. + * + * Summing spans instead would double-count sticky or overlapping items and + * report a viewport as fuller than it is — hiding the empty band this is meant + * to expose. + */ +public fun lazyTraceViewportCoverage( + viewportStartPx: Int, + viewportEndPx: Int, + itemSpans: List> +): Pair { + if (viewportEndPx <= viewportStartPx) return 0 to 0 + val clipped = ArrayList>(itemSpans.size) + for ((start, end) in itemSpans) { + val top = if (start > viewportStartPx) start else viewportStartPx + val bottom = if (end < viewportEndPx) end else viewportEndPx + if (bottom > top) clipped.add(top to bottom) + } + clipped.sortBy { it.first } + var covered = 0 + var runStart = 0 + var runEnd = 0 + var open = false + for ((top, bottom) in clipped) { + if (!open) { + runStart = top + runEnd = bottom + open = true + } else if (top <= runEnd) { + if (bottom > runEnd) runEnd = bottom + } else { + covered += runEnd - runStart + runStart = top + runEnd = bottom + } + } + if (open) covered += runEnd - runStart + val extent = viewportEndPx - viewportStartPx + return covered to (if (extent > covered) extent - covered else 0) +} + +/** + * How a host hands a trace to the lazy layouts inside its own composition. + * + * Static because it is read in the measure path and must not cause + * recomposition, and null by default so a host that provides nothing pays + * nothing. Scoped to the provider's composition, so the handle dies with the + * screen that created it rather than outliving it as global state. + */ +public val LocalLazyLayoutTrace: androidx.compose.runtime.ProvidableCompositionLocal = + staticCompositionLocalOf { null } + +/** + * Issues frame identities for trace records: a monotonic sequence plus a + * monotonic clock reading. Held per lazy layout so sequences do not interleave + * between unrelated lists, and only created when a host supplied a trace. + */ +public class LazyTraceFrameCounter { + private var sequence: Long = 0L + private val start = kotlin.time.TimeSource.Monotonic.markNow() + + public fun next(): LazyTraceFrame { + sequence += 1L + return LazyTraceFrame( + frameSequence = sequence, + frameTimeNanos = start.elapsedNow().inWholeNanoseconds + ) + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KeyboardDismissModeInteractiveIOS.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KeyboardDismissModeInteractiveIOS.kt new file mode 100644 index 000000000..aeb7b66ef --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KeyboardDismissModeInteractiveIOS.kt @@ -0,0 +1,61 @@ +/* + * 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.extension + +import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.node.KNode +import com.tencent.kuikly.compose.ui.node.ModifierNodeElement +import com.tencent.kuikly.compose.ui.node.requireLayoutNode +import com.tencent.kuikly.core.views.ScrollerView + +/** + * Uses UIKit's interactive keyboard dismissal for the first scroller represented by this node. + * This is an iOS-only rendering hint; other platforms retain their existing keyboard behavior. + */ +fun Modifier.keyboardDismissModeInteractiveIOS( + enable: Boolean = true, +): Modifier = this.then(KeyboardDismissModeInteractiveIOSElement(enable)) + +private data class KeyboardDismissModeInteractiveIOSElement( + val enable: Boolean, +) : ModifierNodeElement() { + override fun create(): KeyboardDismissModeInteractiveIOSNode = + KeyboardDismissModeInteractiveIOSNode(enable) + + override fun update(node: KeyboardDismissModeInteractiveIOSNode) { + node.enable = enable + node.update() + } +} + +private class KeyboardDismissModeInteractiveIOSNode( + var enable: Boolean, +) : Modifier.Node() { + override fun onAttach() { + super.onAttach() + update() + } + + fun update() { + val layoutNode = requireLayoutNode() + val kNode = layoutNode as? KNode<*> ?: return + val scrollerView = + (kNode.view as? ScrollerView<*, *>) + ?: layoutNode.findFirstChildScrollerView() + ?: return + scrollerView.getViewAttr().keyboardDismissModeInteractiveIOS(enable) + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KuiklySemantisHandler.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KuiklySemantisHandler.kt index 980a2ef38..ed095d1f4 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KuiklySemantisHandler.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/KuiklySemantisHandler.kt @@ -15,6 +15,7 @@ package com.tencent.kuikly.compose.extension +import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.node.KNode import com.tencent.kuikly.compose.ui.semantics.Role import com.tencent.kuikly.compose.ui.semantics.SemanticsActions @@ -32,22 +33,26 @@ import com.tencent.kuikly.core.base.attr.AccessibilityRole * 主要功能: * 1. 监听并处理 Compose 语义树的变更,自动为节点设置合适的无障碍文本和角色。 * 2. 感知 stateDescription 的新增、变化和消失,并提供回调接口供业务自定义处理。 - * 3. 内部维护节点 stateDescription 的缓存,支持外部主动清理缓存,防止内存泄漏。 + * 3. 内部维护节点 stateDescription 和原生语义状态,支持节点卸载与主动清理,防止内存泄漏。 */ class KuiklySemantisHandler { private val lastStateDescriptionMap = mutableMapOf() + private val nativeSemanticsNodes = NativeSemanticsNodeRegistry>() /** * 语义树变更回调,自动为节点设置无障碍文本和角色,并感知 stateDescription 变化。 * @param semanticsOwner 当前 Compose 语义树的 owner */ + @OptIn(ExperimentalComposeUiApi::class) fun onSemanticsChange(semanticsOwner: SemanticsOwner) { val allNodes = semanticsOwner.getAllSemanticsNodes(mergingEnabled = true) val currentNodeIds = mutableSetOf() + val currentNativeNodes = mutableMapOf>() allNodes.forEach { node -> val config = node.config val role = config.getOrNull(SemanticsProperties.Role) + val isInvisibleToUser = config.getOrNull(SemanticsProperties.InvisibleToUser) != null val stateDescription = config.getOrNull(SemanticsProperties.StateDescription) val nodeId = node.id currentNodeIds.add(nodeId) @@ -55,15 +60,13 @@ class KuiklySemantisHandler { val isClickable = config.getOrNull(SemanticsActions.OnClick) != null val isLongClickable = config.getOrNull(SemanticsActions.OnLongClick) != null (node.layoutNode as? KNode<*>)?.run { + currentNativeNodes[nodeId] = this val accessibility = buildAccessibilityText(node.config) - if (accessibility != "") { - view.getViewAttr().accessibility(accessibility) - val kuiklyAccRole = convertComposeRoleToKuiklyRole(role) - view.getViewAttr().accessibilityRole(kuiklyAccRole) - } - if (isClickable || isLongClickable) { - view.getViewAttr().accessibilityInfo(isClickable, isLongClickable) - } + view.getViewAttr().accessibility(accessibility) + view.getViewAttr().accessibilityRole( + resolveNativeAccessibilityRole(isInvisibleToUser, accessibility.isNotEmpty(), role) + ) + view.getViewAttr().accessibilityInfo(isClickable, isLongClickable) val testTag = config.getOrNull(SemanticsProperties.TestTag) if (testTag != null) { @@ -82,6 +85,27 @@ class KuiklySemantisHandler { lastStateDescriptionMap[nodeId] = stateDescription } } + val unmergedNodes = semanticsOwner.getAllSemanticsNodes(mergingEnabled = false) + val hiddenLayoutNodes = unmergedNodes + .filter { node -> + node.config.getOrNull(SemanticsProperties.InvisibleToUser) != null + } + .mapTo(mutableSetOf()) { node -> node.layoutNode } + // Semantic parent links can stop at merge/clear boundaries while the flattened native + // views remain descendants in the LayoutNode tree. Project hidden state through that + // stable ancestry so every native accessibility candidate is covered. + effectivelyHiddenNodes( + nodes = (allNodes + unmergedNodes).distinctBy(SemanticsNode::id), + firstAncestor = { node -> node.layoutNode }, + isHidden = hiddenLayoutNodes::contains, + parentOf = { layoutNode -> layoutNode.parent } + ).forEach { node -> + (node.layoutNode as? KNode<*>)?.run { + currentNativeNodes[node.id] = this + hideNativeSemantics(this) + } + } + nativeSemanticsNodes.reconcile(currentNativeNodes).forEach(::clearNativeSemantics) val removedIds = lastStateDescriptionMap.keys - currentNodeIds for (removedId in removedIds) { val lastDesc = lastStateDescriptionMap[removedId] @@ -164,20 +188,86 @@ class KuiklySemantisHandler { return textBuilder.toString() } - private fun convertComposeRoleToKuiklyRole(role: Role?): AccessibilityRole { - val kuiklyAccRole = when (role) { - Role.Image -> AccessibilityRole.IMAGE - Role.Checkbox -> AccessibilityRole.CHECKBOX - Role.Switch -> AccessibilityRole.TEXT - Role.Button -> AccessibilityRole.BUTTON - Role.RadioButton -> AccessibilityRole.CHECKBOX - else -> AccessibilityRole.TEXT + private fun clearNativeSemantics(node: KNode<*>) { + node.view.getViewAttr().apply { + accessibility("") + accessibilityRole(AccessibilityRole.NONE) + accessibilityInfo(false, false) } - return kuiklyAccRole + } + + private fun hideNativeSemantics(node: KNode<*>) { + node.view.getViewAttr().accessibilityRole(AccessibilityRole.HIDDEN) + } + + fun onNodeDetached(nodeId: Int) { + lastStateDescriptionMap.remove(nodeId) + nativeSemanticsNodes.remove(nodeId) } fun clearCache() { lastStateDescriptionMap.clear() + nativeSemanticsNodes.clear() } +} + +internal fun resolveNativeAccessibilityRole( + isInvisibleToUser: Boolean, + hasAccessibilityText: Boolean, + role: Role? +): AccessibilityRole = when { + isInvisibleToUser -> AccessibilityRole.HIDDEN + !hasAccessibilityText && role == null -> AccessibilityRole.NONE + role == Role.Image -> AccessibilityRole.IMAGE + role == Role.Checkbox -> AccessibilityRole.CHECKBOX + role == Role.Button -> AccessibilityRole.BUTTON + role == Role.RadioButton -> AccessibilityRole.CHECKBOX + else -> AccessibilityRole.TEXT +} -} \ No newline at end of file +internal class NativeSemanticsNodeRegistry { + private val nodes = mutableMapOf() + + fun reconcile(current: Map): List { + val removed = nodes.mapNotNull { (id, previousNode) -> + val currentNode = current[id] + previousNode.takeIf { currentNode == null || currentNode !== previousNode } + } + nodes.clear() + nodes.putAll(current) + return removed + } + + fun clear(): List = nodes.values.toList().also { nodes.clear() } + + fun remove(id: Int): T? = nodes.remove(id) +} + +internal fun effectivelyHiddenNodes( + nodes: List, + isHidden: (T) -> Boolean, + parentOf: (T) -> T? +): Set = effectivelyHiddenNodes( + nodes = nodes, + firstAncestor = { node -> node }, + isHidden = isHidden, + parentOf = parentOf +) + +internal fun effectivelyHiddenNodes( + nodes: List, + firstAncestor: (T) -> A, + isHidden: (A) -> Boolean, + parentOf: (A) -> A? +): Set = buildSet { + nodes.forEach { node -> + var ancestor: A? = firstAncestor(node) + while (ancestor != null) { + if (isHidden(ancestor)) { + add(node) + break + } + ancestor = parentOf(ancestor) + } + } +} 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/extension/NodeEventBinder.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/NodeEventBinder.kt new file mode 100644 index 000000000..737eac3d8 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/extension/NodeEventBinder.kt @@ -0,0 +1,54 @@ +/* + * 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.extension + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Owns the current delegate behind a callback whose identity is retained by a native node. + * + * A `ComposeNode.factory` or `MakeKuiklyComposeNode.viewInit` block runs only when its node is + * created. Native event callbacks installed there must therefore keep a stable identity while + * dispatching to the latest composition values. + */ +internal class NodeEventBinder

(initialEvent: (P) -> Unit) { + // Snapshot state is intentional: if recomposition is aborted, its delegate update must not + // leak to a native callback before Compose commits that composition. + private var currentEvent: (P) -> Unit by mutableStateOf(initialEvent) + + val event: (P) -> Unit = { parameter -> + currentEvent(parameter) + } + + fun update(event: (P) -> Unit) { + currentEvent = event + } +} + +/** + * Returns a stable one-argument native event callback that always delegates to [event] from the + * latest composition. + */ +@Composable +internal fun

updatedNodeEvent(event: (P) -> Unit): (P) -> Unit { + val binder = remember { NodeEventBinder(event) } + binder.update(event) + return binder.event +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/DrawerInternalPagerState.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/DrawerInternalPagerState.kt index c11612401..2037dac96 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/DrawerInternalPagerState.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/DrawerInternalPagerState.kt @@ -479,15 +479,16 @@ abstract class DrawerInternalPagerState internal constructor( val finalTargetOffset = targetOffset - markSnapAnimationStarted(finalTargetOffset.toInt()) - - kuiklyInfo.run { - val targetOffsetDp = if (isVertical()) { - Offset(scrollView?.curOffsetX ?: 0f, max(0f, targetOffset / getDensity() - 0.01f)) - } else { - Offset(max(0f, targetOffset / getDensity() - 0.01f), scrollView?.curOffsetY ?: 0f) + kuiklyInfo.withCurrentScrollViewBinding { scrollView -> + markSnapAnimationStarted(finalTargetOffset.toInt()) + kuiklyInfo.run { + val targetOffsetDp = if (isVertical()) { + Offset(scrollView.curOffsetX, max(0f, targetOffset / getDensity() - 0.01f)) + } else { + Offset(max(0f, targetOffset / getDensity() - 0.01f), scrollView.curOffsetY) + } + scrollView.setContentOffset(targetOffsetDp.x, targetOffsetDp.y, true) } - scrollView?.setContentOffset(targetOffsetDp.x, targetOffsetDp.y, true) } } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawer.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawer.kt index c277de212..9db418804 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawer.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawer.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.tencent.kuikly.compose.extension.MakeKuiklyComposeNode +import com.tencent.kuikly.compose.extension.updatedNodeEvent import com.tencent.kuikly.compose.foundation.background import com.tencent.kuikly.compose.foundation.clickable import com.tencent.kuikly.compose.foundation.interaction.MutableInteractionSource @@ -39,6 +40,7 @@ import com.tencent.kuikly.compose.ui.unit.Dp import com.tencent.kuikly.compose.ui.unit.dp import com.tencent.kuikly.compose.scroller.kuiklyInfo import com.tencent.kuikly.core.views.DivView +import com.tencent.kuikly.core.base.event.ClickParams import kotlinx.coroutines.launch /** @@ -174,6 +176,8 @@ fun rememberMoveableDrawerState( * @param state The [MoveableDrawerState] to control this drawer. Create with [rememberMoveableDrawerState]. * @param modifier Modifier for the drawer container. * @param scrimColor Color of the scrim overlay shown when the drawer is open. + * @param userScrollEnabled Whether pager gestures and pager scroll semantics are enabled. This + * does not affect programmatic [MoveableDrawerState.open] or [MoveableDrawerState.close] calls. * @param drawerContent Content of the drawer panel. * @param content Main content area. */ @@ -182,19 +186,25 @@ fun MoveableDrawer( state: MoveableDrawerState, modifier: Modifier = Modifier, scrimColor: Color = Color.Black.copy(alpha = 0.3f), + userScrollEnabled: Boolean = true, drawerContent: @Composable () -> Unit, content: @Composable () -> Unit ) { val scope = rememberCoroutineScope() + val closeDrawerEvent: (ClickParams) -> Unit = updatedNodeEvent { _: ClickParams -> + scope.launch { state.close() } + } val drawerProgress by remember { derivedStateOf { state.progress } } + val interactionPolicy = moveableDrawerInteractionPolicy(userScrollEnabled) Box(modifier.fillMaxSize()) { DrawerHorizontalPager( state = state.internalState, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 1, + userScrollEnabled = interactionPolicy.pagerUserScrollEnabled, ) { page -> when (page) { 0 -> { @@ -209,6 +219,8 @@ fun MoveableDrawer( factory = { DivView() }, modifier = Modifier.fillMaxSize(), viewInit = { + // node-event-freshness-allow: stable empty handler only consumes + // the native touch path and captures no composition value. getViewEvent().click { } }, ) @@ -237,9 +249,7 @@ fun MoveableDrawer( scope.launch { state.close() } }, viewInit = { - getViewEvent().click { - scope.launch { state.close() } - } + getViewEvent().click(closeDrawerEvent) }, ) } @@ -249,3 +259,14 @@ fun MoveableDrawer( } } } + +internal data class MoveableDrawerInteractionPolicy( + val pagerUserScrollEnabled: Boolean +) + +internal fun moveableDrawerInteractionPolicy( + userScrollEnabled: Boolean +): MoveableDrawerInteractionPolicy = + MoveableDrawerInteractionPolicy( + pagerUserScrollEnabled = userScrollEnabled + ) diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyList.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyList.kt index b80a2164b..91fab7316 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyList.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyList.kt @@ -22,6 +22,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.Snapshot import com.tencent.kuikly.compose.foundation.ExperimentalFoundationApi import com.tencent.kuikly.compose.foundation.checkScrollableContainerConstraints +import com.tencent.kuikly.compose.diagnostics.LazyTraceFrameCounter +import com.tencent.kuikly.compose.diagnostics.LocalLazyLayoutTrace import com.tencent.kuikly.compose.foundation.gestures.Orientation import com.tencent.kuikly.compose.foundation.layout.Arrangement import com.tencent.kuikly.compose.foundation.layout.PaddingValues @@ -175,7 +177,16 @@ private fun rememberLazyListMeasurePolicy( // graphicsContext: GraphicsContext, /** Scroll behavior for sticky items */ stickyItemsPlacement: StickyItemsPlacement? -) = remember MeasureResult>( +): LazyLayoutMeasureScope.(Constraints) -> MeasureResult { + // Diagnostics handle, if the host provided one for its own composition. + // Read here rather than in the measure lambda so the measure path performs + // no composition-local lookup, and null in ordinary builds. + val lazyLayoutTrace = LocalLazyLayoutTrace.current + // Frame identity for trace records. A monotonic counter plus a monotonic + // clock reading, so records can be ordered and spaced without relying on + // log arrival order. Only allocated when a host actually provided a trace. + val lazyTraceFrameCounter = remember(lazyLayoutTrace) { LazyTraceFrameCounter() } + return remember MeasureResult>( state, contentPadding, reverseLayout, @@ -186,7 +197,12 @@ private fun rememberLazyListMeasurePolicy( verticalArrangement, // graphicsContext, // stickyHeadersEnabled, - stickyItemsPlacement + stickyItemsPlacement, + // The handle carries the host's correlation identity, which changes per IME + // cycle and layout generation. Without it here the measure lambda closes + // over the first handle forever and every later record is attributed to a + // stale cycle — records that look valid and join to the wrong pass. + lazyLayoutTrace ) { { containerConstraints -> state.measurementScopeInvalidator.attachToScope() @@ -344,6 +360,8 @@ private fun rememberLazyListMeasurePolicy( // todo: wrap with snapshot when b/341782245 is resolved val measureResult = measureLazyList( + trace = lazyLayoutTrace, + traceFrame = if (lazyLayoutTrace != null) lazyTraceFrameCounter.next() else null, itemsCount = itemsCount, measuredItemProvider = measuredItemProvider, mainAxisAvailableSize = mainAxisAvailableSize, @@ -374,8 +392,15 @@ private fun rememberLazyListMeasurePolicy( containerConstraints.constrainWidth(width + totalHorizontalPadding), containerConstraints.constrainHeight(height + totalVerticalPadding), emptyMap(), - placement - ) + ) { + placeLazyListChildrenWithInitialNativeViewport( + placementScope = this, + prepareInitialNativeViewport = { + state.prepareInitialNativeViewportBeforePlacement() + }, + placement = placement, + ) + } } ) @@ -386,3 +411,13 @@ private fun rememberLazyListMeasurePolicy( measureResult } } +} + +internal fun placeLazyListChildrenWithInitialNativeViewport( + placementScope: T, + prepareInitialNativeViewport: () -> Unit, + placement: T.() -> Unit, +) { + prepareInitialNativeViewport() + placementScope.placement() +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListMeasure.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListMeasure.kt index fbda7fd72..13f20cee7 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListMeasure.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListMeasure.kt @@ -16,6 +16,13 @@ package com.tencent.kuikly.compose.foundation.lazy +import com.tencent.kuikly.compose.diagnostics.LazyLayoutTrace +import com.tencent.kuikly.compose.diagnostics.LazyTraceFrame +import com.tencent.kuikly.compose.diagnostics.LazyTraceMeasureRecord +import com.tencent.kuikly.compose.diagnostics.LazyTraceStage +import com.tencent.kuikly.compose.diagnostics.LazyTraceWiringError +import com.tencent.kuikly.compose.diagnostics.lazyTraceViewportCoverage + import com.tencent.kuikly.compose.foundation.gestures.Orientation import com.tencent.kuikly.compose.foundation.layout.Arrangement import com.tencent.kuikly.compose.foundation.lazy.layout.ObservableScopeInvalidator @@ -69,7 +76,12 @@ internal fun measureLazyList( placementScopeInvalidator: ObservableScopeInvalidator, // graphicsContext: GraphicsContext, stickyItemsPlacement: StickyItemsPlacement?, - layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult + layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult, + // Diagnostics only. Owned and released by the caller's composition; null in + // ordinary builds, and every use is behind a compile-time switch so nothing + // here runs — or is even computed — when tracing is disabled. + trace: LazyLayoutTrace? = null, + traceFrame: LazyTraceFrame? = null ): LazyListMeasureResult { require(beforeContentPadding >= 0) { "invalid beforeContentPadding" } require(afterContentPadding >= 0) { "invalid afterContentPadding" } @@ -398,6 +410,49 @@ internal fun measureLazyList( } val headerItem = stickingItems.lastOrNull() + trace?.measure { + val targetToken = trace.targetTokenOrNull() + var targetIndex = -1 + var targetOffset = -1 + var targetSize = -1 + val spans = ArrayList>(positionedItems.size) + positionedItems.fastForEach { item -> + val start = item.offset + spans.add(start to (start + item.size)) + if (targetToken != null && item.key.toString() == targetToken) { + targetIndex = item.index + targetOffset = start + targetSize = item.size + } + } + val viewportStart = -beforeContentPadding + val viewportEnd = mainAxisAvailableSize + afterContentPadding + val coverage = lazyTraceViewportCoverage(viewportStart, viewportEnd, spans) + LazyTraceMeasureRecord( + stage = LazyTraceStage.MeasureResult, + function = "measureLazyList", + // Fail closed rather than substituting -1: a record with a + // placeholder frame cannot be joined, and would look like data. + frame = traceFrame + ?: throw LazyTraceWiringError( + "lazy layout trace is enabled but no frame identity was supplied" + ), + constraintsMaxMainAxis = if (isVertical) constraints.maxHeight else constraints.maxWidth, + viewportStartPx = viewportStart, + viewportEndPx = viewportEnd, + scrollToBeConsumed = scrollToBeConsumed, + firstVisibleIndex = firstItem?.index ?: -1, + firstVisibleScrollOffset = currentFirstItemScrollOffset, + visibleItemCount = positionedItems.size, + totalItemCount = itemsCount, + targetIndex = targetIndex, + targetOffsetPx = targetOffset, + targetSizePx = targetSize, + coveredPx = coverage.first, + gapPx = coverage.second + ) + } + return LazyListMeasureResult( firstVisibleItem = firstItem, firstVisibleItemScrollOffset = currentFirstItemScrollOffset, @@ -414,6 +469,50 @@ internal fun measureLazyList( headerItem?.place(this, isLookingAhead) // we attach it during the placement so LazyListState can trigger re-placement placementScopeInvalidator.attachToScope() + // Placement stage, emitted AFTER every place() has returned; this records + // that placement actually ran for this frame, so the interval + // between "measured correctly" and "pixels are white" is not a + // blind spot inferred from timestamps. + trace?.measure { + var placedTargetOffset = -1 + var placedTargetSize = -1 + val token = trace.targetTokenOrNull() + val spans = ArrayList>(positionedItems.size) + positionedItems.fastForEach { placed -> + val top = placed.offset + spans.add(top to (top + placed.size)) + if (token != null && placed.key.toString() == token) { + placedTargetOffset = top + placedTargetSize = placed.size + } + } + val vStart = -beforeContentPadding + val vEnd = mainAxisAvailableSize + afterContentPadding + val cov = lazyTraceViewportCoverage(vStart, vEnd, spans) + LazyTraceMeasureRecord( + stage = LazyTraceStage.Placement, + function = "measureLazyList.layout", + frame = traceFrame + ?: throw LazyTraceWiringError( + "lazy layout trace is enabled but no frame identity was supplied" + ), + constraintsMaxMainAxis = if (isVertical) constraints.maxHeight else constraints.maxWidth, + viewportStartPx = vStart, + viewportEndPx = vEnd, + scrollToBeConsumed = scrollToBeConsumed, + firstVisibleIndex = firstItem?.index ?: -1, + firstVisibleScrollOffset = currentFirstItemScrollOffset, + visibleItemCount = positionedItems.size, + totalItemCount = itemsCount, + targetIndex = positionedItems.firstOrNull { + token != null && it.key.toString() == token + }?.index ?: -1, + targetOffsetPx = placedTargetOffset, + targetSizePx = placedTargetSize, + coveredPx = cov.first, + gapPx = cov.second + ) + } }, scrollBackAmount = scrollBackAmount, visibleItemsInfo = if (noExtraItems) positionedItems else positionedItems.fastFilter { 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..4354f930e 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 @@ -58,6 +58,10 @@ 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.scroller.kuiklyInfo +import com.tencent.kuikly.compose.scroller.InitialLazyListNativeViewportAction +import com.tencent.kuikly.compose.scroller.initialLazyListNativeViewportAction +import com.tencent.kuikly.compose.scroller.isComposeAtTopForScrollSync +import com.tencent.kuikly.compose.scroller.tryExpandStartSize import com.tencent.kuikly.compose.scroller.tryExpandStartSizeNoScroll import com.tencent.kuikly.compose.profiler.RecompositionProfiler import com.tencent.kuikly.compose.material3.internal.identityHashCode @@ -382,6 +386,41 @@ class LazyListState internal val placementScopeInvalidator = ObservableScopeInvalidator() + private var initialNativeViewportPending = true + + /** + * Establishes the native offset during the first non-empty placement, before any child + * frame is published. Platform renderers can therefore apply a queued contentOffset from + * their first layout instead of exposing offset zero and converging 150 ms later. + */ + internal fun prepareInitialNativeViewportBeforePlacement() { + when ( + initialLazyListNativeViewportAction( + pending = initialNativeViewportPending, + hasItems = layoutInfo.totalItemsCount > 0, + isComposeAtTop = isComposeAtTopForScrollSync(), + contentOffset = kuiklyInfo.contentOffset, + composeOffset = kuiklyInfo.composeOffset.toInt(), + isDragging = kuiklyInfo.scrollView?.isDragging == true, + hasScrollView = kuiklyInfo.scrollView != null, + ) + ) { + InitialLazyListNativeViewportAction.Wait -> return + InitialLazyListNativeViewportAction.Complete -> { + initialNativeViewportPending = false + return + } + InitialLazyListNativeViewportAction.Prepare -> Unit + } + + kuiklyInfo.deferredScrollOffsetAlignmentCoordinator.cancelAndInvalidate { it.cancel() } + kuiklyInfo.offsetDirty = true + tryExpandStartSize(offset = 0, isScrolling = false) + if (kuiklyInfo.composeOffset > 0f) { + initialNativeViewportPending = false + } + } + // TODO: Coroutine scrolling APIs will allow this to be private again once we have more // fine-grained control over scrolling // @VisibleForTesting 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/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/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/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/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..c48b77129 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 @@ -27,6 +28,7 @@ import androidx.compose.runtime.setValue import com.tencent.kuikly.compose.KuiklyApplier import com.tencent.kuikly.compose.extension.SetEventElement import com.tencent.kuikly.compose.extension.SetPropElement +import com.tencent.kuikly.compose.extension.updatedNodeEvent import com.tencent.kuikly.compose.foundation.interaction.Interaction import com.tencent.kuikly.compose.foundation.interaction.MutableInteractionSource import com.tencent.kuikly.compose.foundation.layout.Box @@ -53,6 +55,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 @@ -74,6 +78,7 @@ import com.tencent.kuikly.compose.ui.unit.dp import com.tencent.kuikly.compose.ui.unit.isSpecified import com.tencent.kuikly.compose.ui.util.fastRoundToInt import com.tencent.kuikly.core.views.AutoHeightTextAreaView +import com.tencent.kuikly.core.views.InputEventHandlerFn import com.tencent.kuikly.core.views.LengthLimitType import com.tencent.kuikly.compose.foundation.text.selection.LocalTextSelectionColors import com.tencent.kuikly.core.views.TextAreaAttr @@ -189,7 +194,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) } @@ -199,12 +210,10 @@ 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) } - // 标记是否正在处理原生事件,避免 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 = {} @@ -301,6 +310,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, @@ -311,6 +325,7 @@ internal fun CoreTextField( return@textFieldFocusModifier } hasFocus = it.isFocused + state.hasFocus = it.isFocused if (it.isFocused && enabled && !readOnly) { requireOwner().softwareKeyboardController.startInput(autoHeightTextAreaView) @@ -319,20 +334,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 @@ -364,6 +365,55 @@ internal fun CoreTextField( val (propsAndEvents, others) = remember(modifier) { modifier.splitByPropOrEvent() } val combinedModifier = others.then(focusModifier) + // ComposeNode.factory retains these native callbacks for the node lifetime. The stable wrapper + // prevents a prewarmed disabled/read-only composition from becoming the callback's permanent + // policy after the same node is activated, and also refreshes the reverse transition. + val inputFocusEvent: InputEventHandlerFn = updatedNodeEvent { params -> + if (params.focusIntentOnly) { + if (enabled && !readOnly) { + val intentDecision = + kuiklyKeyboardController?.onNativeFocusIntent(autoHeightTextAreaView) + if ( + intentDecision == InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus || + intentDecision == null + ) { + focusRequester.focusIfAttached() + } + } + } else { + val nativeFocusDecision = kuiklyKeyboardController?.onNativeFocus( + autoHeightTextAreaView, + params.focusRequestId, + ) + if (!enabled || readOnly) { + kuiklyKeyboardController?.rejectNativeFocus(autoHeightTextAreaView) + } else { + 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. + if (!focusRequester.focusIfAttached()) { + kuiklyKeyboardController?.rejectNativeFocus(autoHeightTextAreaView) + } + } + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale -> Unit + } + } + } + } + val inputBlurEvent: InputEventHandlerFn = updatedNodeEvent { params -> + if ( + kuiklyKeyboardController?.onNativeBlur( + autoHeightTextAreaView, + params.focusRequestId, + ) == InputFocusTargetReducer.NativeBlurDecision.RequestComposeClear + ) { + focusManager.clearFocus() + } + } + Box(modifier = pointerModifier.then(combinedModifier), propagateMinConstraints = true) { decorationBox { ComposeNode( @@ -372,9 +422,8 @@ internal fun CoreTextField( KNode(textView) { getViewAttr().autofocus(false) getViewAttr().enablePinyinCallback(true) - getViewEvent().inputFocus { - focusRequester.requestFocus() - } + getViewEvent().inputFocus(inputFocusEvent) + getViewEvent().inputBlur(inputBlurEvent) } }, @@ -387,13 +436,6 @@ internal fun CoreTextField( // 从父亲抽取 TextField 相关的Modifier this.modifier = propsAndEvents } - set(hasFocus) { - withTextAreaView { - if (hasFocus) { - focus() - } - } - } set(editable) { withTextAreaView { getViewAttr().editable(editable) @@ -445,46 +487,31 @@ internal fun CoreTextField( set(Triple(onValueChange, onLimitChange, maxLength)) { withTextAreaView { getViewEvent().textInputStateChange { - // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 - isProcessingNativeEvent = true - pendingTextInputStateText = it.text + val textFieldValue = textInputCallbackArbiter.onCompleteState(it) lastSyncedTextInputState = TextInputState( text = it.text, selectionStart = it.selectionStart, selectionEnd = it.selectionEnd, compositionStart = it.compositionStart, compositionEnd = it.compositionEnd, - length = it.length + length = it.length, ) 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, ) + onValueChange(textFieldValue) dispatchLimitChange(it.length, pendingLimitChangeNotification) } getViewEvent().selectionChange { - // 标记正在处理原生事件,避免 set(value) 反向同步导致选择状态被重置 - isProcessingNativeEvent = true lastSyncedTextInputState = TextInputState( text = it.text, selectionStart = it.selectionStart, selectionEnd = it.selectionEnd, compositionStart = it.compositionStart, compositionEnd = it.compositionEnd, - length = it.length + length = it.length, ) val composition = if ( it.compositionStart != TextInputState.NO_COMPOSITION && @@ -494,32 +521,41 @@ 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, ) + onValueChange(textFieldValue) } getViewEvent().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, + ) + controlledStateArbiter.recordNativeValue( + fallbackValue, + ) + onValueChange(fallbackValue) dispatchLimitChange(it.length, pendingLimitChangeNotification) } } @@ -566,24 +602,32 @@ internal fun CoreTextField( } } - set(value) { - if (it == null) return@set + set(value) { controlledValue -> withTextAreaView { - val composition = value.composition + 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 an older caller-held callback object is + // applied. Exact callback-object identity is the only provenance available + // here: direct native echoes fail closed, while a formatter or external owner + // asserts authority with a distinct, observable controlled editing state. + val shouldSuppressControlledUpdate = + controlledStateArbiter.shouldSuppressControlledUpdate( + value = controlledValue, + ) + val shouldSyncToNative = !shouldSuppressControlledUpdate && !(lastSyncedTextInputState?.hasSameEditingState(incomingTextInputState) ?: false) if (shouldSyncToNative) { + // The prop cache mirrors native state. Never poison it with a suppressed + // stale down-value before the native editor has actually accepted it. + getViewAttr().updatePropCache(TextConst.VALUE, incomingTextInputState.text) setTextInputState(incomingTextInputState) lastSyncedTextInputState = incomingTextInputState } @@ -591,8 +635,6 @@ internal fun CoreTextField( // 长度计算统一依赖原生层回调,避免 Kotlin 层和原生层计算不一致 // 原生层会在 textInputStateChange 回调中返回正确的 length - // 重置标志,等待下一次原生事件 - isProcessingNativeEvent = false } } }, @@ -600,6 +642,119 @@ internal fun CoreTextField( } } } + +internal fun FocusRequester.focusIfAttached(): Boolean = + hasAttachedNodes() && focus() + +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 nativeValueTokens = mutableListOf() + + fun recordNativeValue(value: TextFieldValue) { + if (nativeValueTokens.lastOrNull() === value) { + return + } + nativeValueTokens += value + if (nativeValueTokens.size > MAX_NATIVE_VALUE_TOKENS) { + nativeValueTokens.removeAt(0) + } + } + + fun shouldSuppressControlledUpdate( + value: TextFieldValue, + ): 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. + // Exact native callback objects remain native-origin tokens for the mounted editor. Treat + // them as direct echoes inside the bounded provenance window. A formatter or external + // reset asserts authority with a distinct object that reaches controlled reconciliation. + // Retaining an exact older callback object to reject a later edit is indistinguishable + // from a stale echo and is outside this fence's contract; that requires an explicit, + // atomically observed controlled acknowledgement rather than inferred ordering. + return nativeValueTokens.any { it === value } + } + + private companion object { + const val MAX_NATIVE_VALUE_TOKENS = 64 + } +} + /** * 将 Modifier 拆分为两部分:SetPropElement/SetEventElement 和其他 Element * 使用 foldOut 从内到外遍历,保持原始顺序 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..2e4088f1a 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 @@ -53,6 +55,9 @@ 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 = "raft.build.markdown.inlineCode" +private const val SLOCK_INLINE_CODE_TRAILING_MARGIN_ANNOTATION_TAG = + "raft.build.markdown.inlineCodeTrailingMargin" // Returns platform-specific default font size private fun TextAttr.defaultFontSize(): Float { @@ -263,6 +268,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 } @@ -322,19 +330,63 @@ internal fun RichTextAttr.applyAnnotatedString( positions.add(range.end) } + // Collect LinkAnnotation positions + val linkAnnotations = annoText.getLinkAnnotations(0, annoText.length) + linkAnnotations.forEach { range -> + positions.add(range.start) + positions.add(range.end) + } + + 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) } - // Collect LinkAnnotation positions - val linkAnnotations = annoText.getLinkAnnotations(0, annoText.length) - linkAnnotations.forEach { range -> + // 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) + } + 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) } - // Collect placeholder info and positions val (placeholders, _) = if (annoText.hasInlineContent()) { annoText.resolveInlineContent(inlineContent) @@ -350,10 +402,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 { @@ -363,24 +446,72 @@ 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 + val linkAnnotation = linkAnnotations + .firstOrNull { range -> !(end <= range.start || start >= range.end) } + val overlappingSpanStyles = annoText.spanStyles .filter { range -> !(end <= range.start || start >= range.end) } - .forEach { range -> applySpanStyle(range.item, density) } + if (inlineBoxRange != null) { + // 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( + background = Color.Unspecified, + inlineBoxStyle = null, + ), + density, + ) + } + + 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() + } + if (slockInlineCodeTrailingMarginAnnotations.any { range -> start >= range.start && end <= range.end }) { + slockInlineCodeTrailingMargin() + } // Apply ParagraphStyle annoText.paragraphStyles .filter { range -> !(end <= range.start || start >= range.end) } @@ -392,14 +523,16 @@ 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 -> - val spanStyle = range.item.styles?.style ?: SpanStyle() - applySpanStyle(spanStyle, density) + if (inlineBoxRange == null) { + // A style-less link is interaction metadata only. Applying + // an empty SpanStyle writes empty font props onto the core + // span and erases inherited/custom families (for example + // Space Grotesk on a whole-body click annotation). + range.item.styles?.style?.let { spanStyle -> + applySpanStyle(spanStyle, density) + } + } // Add click event handler click { _ -> @@ -409,9 +542,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 { @@ -428,16 +563,29 @@ 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)) } + applyFontFamily(spanStyle.fontFamily) applyFontWeight(spanStyle.fontWeight) applyFontStyle(spanStyle.fontStyle) applyShadow(spanStyle.shadow) applyStyleColor(spanStyle) + 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) { @@ -454,9 +602,32 @@ 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) { 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/SelectableText.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/SelectableText.kt new file mode 100644 index 000000000..615b3d130 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/foundation/text/SelectableText.kt @@ -0,0 +1,305 @@ +/* + * 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.foundation.text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.tencent.kuikly.compose.extension.MakeKuiklyComposeNode +import com.tencent.kuikly.compose.ui.Modifier +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.graphics.isSpecified +import com.tencent.kuikly.compose.ui.input.pointer.nativeDispatchRelease +import com.tencent.kuikly.compose.ui.layout.Measurable +import com.tencent.kuikly.compose.ui.layout.IntrinsicMeasurable +import com.tencent.kuikly.compose.ui.layout.IntrinsicMeasureScope +import com.tencent.kuikly.compose.ui.layout.MeasurePolicy +import com.tencent.kuikly.compose.ui.layout.MeasureResult +import com.tencent.kuikly.compose.ui.layout.MeasureScope +import com.tencent.kuikly.compose.ui.node.KNode +import com.tencent.kuikly.compose.ui.node.MeasureScopeWithLayoutNode +import com.tencent.kuikly.compose.ui.platform.LocalDensity +import com.tencent.kuikly.compose.ui.text.TextStyle +import com.tencent.kuikly.compose.ui.text.style.TextAlign +import com.tencent.kuikly.compose.ui.unit.Constraints +import com.tencent.kuikly.compose.ui.unit.IntSize +import com.tencent.kuikly.compose.ui.unit.constrain +import com.tencent.kuikly.compose.ui.unit.isSpecified +import com.tencent.kuikly.core.views.SelectableTextAttr +import com.tencent.kuikly.core.views.SelectableTextView +import com.tencent.kuikly.core.views.TextConst +import kotlin.math.ceil + +/** + * System-selectable plain text. + * + * Renders [text] on the platform's native selectable text surface + * (Android `TextView.setTextIsSelectable`, iOS `UITextView` with + * `editable = false` / `selectable = true`, OHOS `Text` with the system copy + * option). The OS supplies the selection experience anchored to the + * selection: the baseline guarantee is word selection, drag handles, + * Select all and Copy. Any further actions (e.g. Translate, Look Up, Share, + * or PROCESS_TEXT targets on Android) appear only if the current OS version, + * locale and installed services provide them — they are platform-supplied + * extras, not guarantees of this component. The surface is strictly + * read-only: it never opens an IME and text can only change via [text]. + * + * Scrolling is not built in; wrap in a scrollable container for long content. + * + * Supported [style] fields: color, fontSize, fontWeight, lineHeight, + * textAlign. Other fields are ignored by this minimal surface. Unspecified + * fields resolve to deterministic defaults (black, 15f, 400, fontSize*4/3, + * left) so style changes on a reused node always reset prior values. + */ +@Composable +fun SelectableText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, +) { + val density = LocalDensity.current + // Lazy containers measure items with an unbounded main axis. The generic + // Kuikly node policy lays out at constraints.maxHeight directly, which is + // Constraints.Infinity for a vertical LazyColumn. Measure this text-backed + // native node through its TextShadow instead so the Compose node receives + // the finite content height that the native view will render at. + val measurePolicy = remember(text, style, density.density) { + selectableTextMeasurePolicy() + } + MakeKuiklyComposeNode( + factory = { SelectableTextView() }, + // The system selection gesture lives in the native view. Release this + // region from any ancestor native-dispatch capture (overlay barriers) + // so the native text view keeps receiving MotionEvents; the release is + // branch-scoped, so barriers still block click-through elsewhere. + modifier = modifier.nativeDispatchRelease(), + measurePolicy = measurePolicy, + viewUpdate = { view -> + view.getViewAttr().run { + text(text) + val densityScale = density.density / getPager().pagerDensity() + resolveSelectableTextStyleProps(style, densityScale).applyTo(this) + } + } + ) +} + +private const val SELECTABLE_TEXT_UNBOUNDED_WIDTH = 100000f +private const val SELECTABLE_TEXT_UNBOUNDED_HEIGHT = -1f + +internal fun selectableTextShadowConstraint( + maxDimension: Int, + pagerDensity: Float, + unboundedValue: Float, +): Float = + if (maxDimension == Constraints.Infinity) { + unboundedValue + } else { + maxDimension.toFloat() / pagerDensity + } + +internal fun selectableTextMeasuredSize( + constraints: Constraints, + measuredWidth: Float, + measuredHeight: Float, + pagerDensity: Float, +): IntSize = + constraints.constrain( + IntSize( + width = ceil(measuredWidth * pagerDensity).toInt(), + height = ceil(measuredHeight * pagerDensity).toInt(), + ) + ) + +internal fun selectableTextViewForMeasure(scope: IntrinsicMeasureScope): SelectableTextView { + val layoutNode = + (scope as? MeasureScopeWithLayoutNode)?.layoutNode + ?: error("SelectableText measure scope must expose its layout node") + return ((layoutNode as? KNode<*>)?.view as? SelectableTextView) + ?: error("SelectableText measure policy must run on a SelectableText KNode") +} + +internal fun selectableTextMeasurePolicy( + pagerDensity: (SelectableTextView) -> Float = { it.getPager().pagerDensity() }, +): MeasurePolicy = + object : MeasurePolicy { + private val placementBlock: com.tencent.kuikly.compose.ui.layout.Placeable.PlacementScope.() -> Unit = {} + + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints, + ): MeasureResult { + check(measurables.isEmpty()) { "SelectableText must remain a leaf native node" } + // Resolve the native view from the KNode that Compose is actually + // measuring. A ReusableComposeNode may retain its KNode while + // ordinary remember caches are recreated for replacement content. + val view = selectableTextViewForMeasure(this) + val pagerDensity = pagerDensity(view) + val measured = + view.calculateContentSize( + maxWidth = + selectableTextShadowConstraint( + maxDimension = constraints.maxWidth, + pagerDensity = pagerDensity, + unboundedValue = SELECTABLE_TEXT_UNBOUNDED_WIDTH, + ), + maxHeight = + selectableTextShadowConstraint( + maxDimension = constraints.maxHeight, + pagerDensity = pagerDensity, + unboundedValue = SELECTABLE_TEXT_UNBOUNDED_HEIGHT, + ), + ) + val size = + selectableTextMeasuredSize( + constraints = constraints, + measuredWidth = measured?.width ?: 0f, + measuredHeight = measured?.height ?: 0f, + pagerDensity = pagerDensity, + ) + return layout(size.width, size.height, placementBlock = placementBlock) + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurables: List, + height: Int, + ): Int = intrinsicSize(measurables, Constraints.Infinity, height).width + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List, + height: Int, + ): Int = intrinsicSize(measurables, Constraints.Infinity, height).width + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurables: List, + width: Int, + ): Int = intrinsicSize(measurables, width, Constraints.Infinity).height + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurables: List, + width: Int, + ): Int = intrinsicSize(measurables, width, Constraints.Infinity).height + + private fun IntrinsicMeasureScope.intrinsicSize( + measurables: List, + maxWidth: Int, + maxHeight: Int, + ): IntSize { + check(measurables.isEmpty()) { "SelectableText must remain a leaf native node" } + // Unlike MeasurePolicy's default intrinsic implementation, this + // keeps the original coordinator receiver, which exposes the + // actual KNode instead of wrapping it in IntrinsicsMeasureScope. + val view = selectableTextViewForMeasure(this) + val density = pagerDensity(view) + val measured = + view.calculateContentSize( + maxWidth = + selectableTextShadowConstraint( + maxDimension = maxWidth, + pagerDensity = density, + unboundedValue = SELECTABLE_TEXT_UNBOUNDED_WIDTH, + ), + maxHeight = + selectableTextShadowConstraint( + maxDimension = maxHeight, + pagerDensity = density, + unboundedValue = SELECTABLE_TEXT_UNBOUNDED_HEIGHT, + ), + ) + return IntSize( + width = ceil((measured?.width ?: 0f) * density).toInt().coerceAtLeast(0), + height = ceil((measured?.height ?: 0f) * density).toInt().coerceAtLeast(0), + ) + } + } + +/** + * The resolved native prop values for a [SelectableText] style. Kept as plain + * data so the mapping from Compose types is unit-testable. + * + * Every field is always concrete (never null): the compose node backing + * SelectableText is reusable, so a `specified -> TextStyle.Default` update + * must actively overwrite every previously written prop on both the native + * renderer and the measuring TextShadow. Unspecified style fields therefore + * resolve to deterministic defaults instead of "don't write". + */ +internal data class SelectableTextStyleProps( + val color: String, + val fontSize: Float, + val fontWeight: String, + val lineHeight: Float, + val textAlign: String, +) { + fun asPropPairs(): List> = listOf( + TextConst.TEXT_COLOR to color, + TextConst.FONT_SIZE to fontSize, + TextConst.FONT_WEIGHT to fontWeight, + TextConst.LINE_HEIGHT to lineHeight, + TextConst.TEXT_ALIGN to textAlign, + ) +} + +internal const val SELECTABLE_TEXT_DEFAULT_FONT_SIZE = 15f +internal const val SELECTABLE_TEXT_DEFAULT_LINE_HEIGHT_FACTOR = 4f / 3f + +internal fun resolveSelectableTextStyleProps( + style: TextStyle, + densityScale: Float, +): SelectableTextStyleProps { + val color = if (style.color.isSpecified) { + style.color.toKuiklyColor().toString() + } else { + Color.Black.toKuiklyColor().toString() + } + val fontSize = if (style.fontSize.isSpecified) { + style.fontSize.value * densityScale + } else { + SELECTABLE_TEXT_DEFAULT_FONT_SIZE + } + val fontWeight = style.fontWeight?.let { weight -> + when { + weight.weight >= 700 -> "700" + weight.weight >= 600 -> "600" + weight.weight >= 500 -> "500" + else -> "400" + } + } ?: "400" + // No wire value reliably means "auto" on every consumer (the shared + // rich-text shadow converts before comparing to its unset sentinel), so + // unspecified lineHeight resolves to a deterministic default derived from + // the resolved fontSize. + val lineHeight = if (style.lineHeight.isSpecified) { + style.lineHeight.value * densityScale + } else { + fontSize * SELECTABLE_TEXT_DEFAULT_LINE_HEIGHT_FACTOR + } + // textAlign is non-null in this fork; Unspecified is a sentinel value. + val textAlign = when (style.textAlign.value) { + TextAlign.Center.value -> "center" + TextAlign.Right.value, TextAlign.End.value -> "right" + else -> "left" + } + return SelectableTextStyleProps( + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + lineHeight = lineHeight, + textAlign = textAlign, + ) +} + +internal fun SelectableTextStyleProps.applyTo(attr: SelectableTextAttr) { + asPropPairs().forEach { (key, value) -> attr.setProp(key, 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/gestures/KuiklyScrollInfo.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfo.kt index 546c37e6c..fc1550df8 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,16 +18,90 @@ 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 import com.tencent.kuikly.core.views.ScrollerView +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import com.tencent.kuikly.compose.diagnostics.LazyLayoutTraceConfig +import com.tencent.kuikly.core.log.KLog + +internal class ScrollViewBindingGate { + private val binding = MutableStateFlow(null) + + var current: T? = null + private set + + fun update(value: T?) { + current = value + binding.value = value + } + + suspend fun withCurrentBinding(block: (T) -> R): R { + while (true) { + val candidate = binding.filterNotNull().first() + if (current === candidate) { + return block(candidate) + } + } + } +} + +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 @@ -39,22 +113,91 @@ class KuiklyScrollInfo { private const val DEFAULT_DENSITY = 3f } + private val scrollViewBinding = + ScrollViewBindingGate>() + /** * Scroll offset that needs to be ignored */ 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 + // task #990 diagnostic: this clears the pending programmatic offset + // whether or not it matched. Seeing null later at a viewport shrink is + // therefore ambiguous between "never installed" and "already consumed + // by an earlier native echo" — record the consumption so the two are + // distinguishable. + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=KuiklyScrollInfo.consumeIgnoredScrollOffset " + + "consumed=$ignoredOffset nativeEcho=($offsetX,$offsetY) matched=$matched" + ) + } + ignoreScrollOffset = null + 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 */ var scrollView: ScrollerView? = null set(value) { field = value + scrollViewBinding.update(value) if (hasPullToRefresh && value != null) { - value.setHasPullToRefresh(true) + updatePullToRefreshOnScrollView(value, true) } } + internal suspend fun withCurrentScrollViewBinding( + block: (ScrollerView) -> R + ): R = scrollViewBinding.withCurrentBinding(block) + /** * Scroll orientation */ @@ -107,6 +250,12 @@ class KuiklyScrollInfo { */ internal var appleScrollViewOffsetJob: Job? = null + internal val deferredScrollOffsetAlignmentCoordinator = + DeferredScrollOffsetAlignmentCoordinator( + pendingAlignment = { appleScrollViewOffsetJob }, + updatePendingAlignment = { appleScrollViewOffsetJob = it } + ) + /** * Coroutine scope */ @@ -131,12 +280,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, @@ -181,8 +350,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 @@ -233,7 +401,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() } /** @@ -255,4 +427,4 @@ class KuiklyScrollInfo { val threshold = SCROLL_BOTTOM_THRESHOLD * getDensity() return contentOffset + viewportSize + threshold > currentContentSize } -} \ No newline at end of file +} 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..45c2a009a 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 @@ -88,6 +89,10 @@ internal fun KNode<*>.resetViewVisible() { viewVisible?.let { view.getViewAttr().visibility(it) viewVisible = null + // An offscreen lazy slot can be drawn clean while hidden. Restoring the native + // visibility prop must dirty this exact descendant as well as its ancestry; + // waking only the slot container leaves a clean child unable to flush the prop. + invalidateDrawAndForceAncestors() } } } @@ -121,6 +126,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 +166,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 +188,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/material3/PullToRefresh.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/material3/PullToRefresh.kt index 3eb190d1f..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. @@ -175,6 +223,11 @@ 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. + * Runtime changes to this value and [refreshThreshold] take effect without replacing + * [scrollState]. * @param content Custom refresh indicator content */ fun LazyListScope.pullToRefreshItem( @@ -184,6 +237,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 +257,7 @@ fun LazyListScope.pullToRefreshItem( modifier = modifier, topInset = topInset, refreshThreshold = refreshThreshold, + holdRefreshInset = holdRefreshInset, content = content ) } @@ -220,6 +275,7 @@ internal fun PullToRefreshItem( modifier: Modifier = Modifier, topInset: Dp = 0.dp, refreshThreshold: Dp = 80.dp, + holdRefreshInset: Boolean = true, content: @Composable ( pullProgress: Float, isRefreshing: Boolean, @@ -232,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() } @@ -242,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 @@ -267,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) @@ -284,33 +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 = 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(currentHoldRefreshInset) pullToRefreshLog { - "PULLING -> REFRESHING (release): offset=$contentOffset " + - "pullDistance=$pullDistance" + "PULLING -> $releasedState (release): offset=$contentOffset " + + "pullDistance=$pullDistance holdRefreshInset=$currentHoldRefreshInset" } - state.updatePullState(PullState.REFRESHING) - updatedOnRefresh() + state.releasePullToRefresh( + snapshot = snapshot, + clearEndDragInset = { + scrollView?.setContentInsetWhenEndDrag(top = 0f) + }, + clearCurrentInset = { + scrollView?.setContentInset(top = 0f, animated = false) + }, + onRefresh = updatedOnRefresh + ) } } } @@ -324,13 +417,19 @@ internal fun PullToRefreshItem( } // Handle inset changes based on pull state - LaunchedEffect(state.pullState) { + LaunchedEffect(state.pullState, holdRefreshInset, refreshThresholdLogical) { 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 +462,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 +488,46 @@ 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 + +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 */ @@ -418,4 +557,4 @@ private fun DefaultRefreshIndicator( modifier = Modifier.padding(16.dp) ) } -} \ No newline at end of file +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionObserver.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionObserver.kt index 5732da67c..96bc9024f 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionObserver.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionObserver.kt @@ -28,176 +28,106 @@ import androidx.compose.runtime.tooling.observe * * Leverages [CompositionObserver.onBeginComposition]'s `invalidationMap` to determine * exactly which State objects triggered each RecomposeScope's invalidation. - * Combined with [RecomposeScopeObserver] to maintain an active scope stack, + * Combined with [RecomposeScopeObserver] to maintain composition- and thread-partitioned scope stacks, * this allows [RecompositionTracker] to associate precise trigger states * with each Composable (via the CompositionTracer bridge). * * Data flow: * 1. `onBeginComposition(invalidationMap)` → save scope→states mapping * 2. `RecomposeScopeObserver.onBeginScopeComposition(scope)` → push to active stack - * 3. `CompositionTracer.traceEventStart(key, info)` → tracker records composable start - * 4. `CompositionTracer.traceEventEnd()` → tracker queries [getCurrentScopeTriggerStates] + * 3. `CompositionTracer.traceEventStart(key, info)` → tracker atomically captures + * [currentScopeSnapshot] with the calling thread's composable entry + * 4. `CompositionTracer.traceEventEnd()` → tracker pops and consumes that same entry snapshot * 5. `RecomposeScopeObserver.onEndScopeComposition(scope)` → pop from active stack * 6. `onEndComposition()` → cleanup */ @OptIn(ExperimentalComposeRuntimeApi::class) -internal class ProfilerCompositionObserver( - private val tracker: RecompositionTracker -) : CompositionObserver { +internal class ProfilerCompositionObserver : CompositionObserver { - /** - * Current frame's precise scope → trigger states mapping. - * Populated by [onBeginComposition], cleared by [onEndComposition]. - */ - private val scopeToStatesMap = mutableMapOf?>() - - /** - * Active scope stack. Maintained by [RecomposeScopeObserver] callbacks. - * The top of the stack is the currently executing scope. - */ - private val activeScopeStack = mutableListOf() - - /** - * Handles for scope observers registered in the current composition pass. - * Disposed at the end of composition to avoid leaks. - */ - private val scopeObserverHandles = mutableListOf() - - /** - * Whether precise scope→state mapping is available for the current composition pass. - */ - internal var hasPreciseMapping: Boolean = false - private set + private val stateRegistry = ProfilerCompositionStateRegistry< + Composition, + RecomposeScope, + CompositionObserverHandle + >() override fun onBeginComposition( composition: Composition, invalidationMap: Map?> ) { - // Clean up previous scope observer handles - for (handle in scopeObserverHandles) { - handle.dispose() - } - scopeObserverHandles.clear() - - // Save precise scope → states mapping - scopeToStatesMap.clear() - scopeToStatesMap.putAll(invalidationMap) - - activeScopeStack.clear() - hasPreciseMapping = true + val beginResult = stateRegistry.beginComposition(composition, invalidationMap) + disposeHandles(beginResult.handlesToDispose) // Register RecomposeScopeObserver for each invalidated scope // This is necessary so that onBeginScopeComposition/onEndScopeComposition // are called by the runtime when each scope's compose lambda executes. - val scopeObserver = ScopeObserver() + val scopeObserver = ScopeObserver(composition, beginResult.generation) for ((scope, _) in invalidationMap) { val handle = scope.observe(scopeObserver) - scopeObserverHandles.add(handle) + if (!stateRegistry.registerHandle(composition, beginResult.generation, handle)) { + // The pass ended/restarted while observe() was creating the handle. + // Dispose outside the registry lock so callbacks may safely re-enter. + handle.dispose() + } } - - // Notify tracker - tracker.onCompositionObserverBegin() } override fun onEndComposition(composition: Composition) { - // Notify tracker - tracker.onCompositionObserverEnd() - - // Dispose scope observer handles - for (handle in scopeObserverHandles) { - handle.dispose() - } - scopeObserverHandles.clear() - - // Cleanup frame-level data - activeScopeStack.clear() - scopeToStatesMap.clear() - hasPreciseMapping = false + disposeHandles(stateRegistry.endComposition(composition)) } /** - * Get the currently active scope's identity hash code. - * Returns null if no active scope (initial composition). - */ - fun getCurrentScopeKey(): Int? = activeScopeStack.lastOrNull()?.hashCode() - - /** - * Get the precise trigger states for the currently active scope (top of stack). + * Returns one atomic snapshot for the calling execution thread. * - * **语义说明:** - * 返回的 State 列表含义是"该 scope 依赖的 State 中,本次 Snapshot.apply 批次里发生了值变化的那些", - * 即 `invalidationMap[scope]` 的内容。 - * - * 底层来源(Composition.kt `invalidateChecked`):每当某个 State 变化时,Runtime 调用 - * `invalidations.add(scope, instance)` 将该 State 记录为触发该 scope 失效的原因。 - * `invalidationMap[scope]` = 本次 apply 批次中,所有「变化了」且「被该 scope 读取」的 State。 - * - * 常见的"多 State 出现"原因: - * - 同一个事件 lambda 中同时修改了多个 State(如 `clickCount++; userName = ...`), - * 它们在同一次 Snapshot.apply 中被 commit,该 scope 依赖的所有这些 State 都会出现; - * - 子组件 scope 也可能出现在 invalidationMap 中,携带父 scope 的 invalidation 原因。 - * - * 这是 Compose Runtime API 的设计限制,无法从 `invalidationMap` 进一步细分 - * "是哪个 State 才是真正触发这次重组的那一个"。 - * 如需精确到参数级别,应结合 `paramChanges`(编译器 `$dirty` bitmask)判断。 - * - * @return List of state identifiers that triggered the current scope's recomposition, - * or null if no active scope / scope not in invalidationMap (e.g., initial composition). + * The global CompositionTracer can observe Android compositions that do not have a matching + * precise observer context. Such calls deliberately return [hasPreciseMapping] = false so the + * tracker falls back to frame-level state changes instead of borrowing another scene's scope. */ - fun getCurrentScopeTriggerStates(): List? { - val currentScope = activeScopeStack.lastOrNull() ?: return null - val states = scopeToStatesMap[currentScope] - // states == null means forced recomposition (not in invalidationMap at all means initial) - return if (states != null) { - states.map { stateToString(it) } - } else if (scopeToStatesMap.containsKey(currentScope)) { - // Key exists but value is null → forced recomposition - listOf("[forced recomposition]") - } else { - // Key doesn't exist → initial composition or child scope, no trigger info - null - } + fun currentScopeSnapshot(): CurrentScopeSnapshot { + val snapshot = stateRegistry.currentScopeSnapshot() + return CurrentScopeSnapshot( + scopeKey = snapshot.scope?.hashCode(), + triggerStateObjects = snapshot.triggerStateObjects, + hasPreciseMapping = snapshot.hasPreciseMapping, + isForcedRecomposition = snapshot.isForcedRecomposition + ) } - /** - * Get the raw trigger State objects for the currently active scope. - * Used by [RecompositionTracker] to register reader mappings with - * the human-readable Composable name from CompositionTracer info. - * - * @return Set of State objects that triggered the current scope, or null if unavailable. - */ - fun getCurrentScopeTriggerStateObjects(): Set? { - val currentScope = activeScopeStack.lastOrNull() ?: return null - return scopeToStatesMap[currentScope] + /** Detach every per-scope handle during profiler stop, then dispose outside the registry lock. */ + fun dispose() { + disposeHandles(stateRegistry.disposeAll()) } - /** - * Convert a State object to a human-readable string identifier. - * Uses [StateIdentityRegistry] to produce "State(prev=x, now=y), readers: Name" format. - */ - private fun stateToString(state: Any): String { - return tracker.stateIdentityRegistry.formatState(state) + private fun disposeHandles(handles: List) { + for (handle in handles) { + handle.dispose() + } } /** * Inner RecomposeScopeObserver that tracks scope enter/exit for stack maintenance. */ - private inner class ScopeObserver : RecomposeScopeObserver { + private inner class ScopeObserver( + private val composition: Composition, + private val generation: Long + ) : RecomposeScopeObserver { override fun onBeginScopeComposition(scope: RecomposeScope) { - activeScopeStack.add(scope) + stateRegistry.beginScope(composition, generation, scope) } override fun onEndScopeComposition(scope: RecomposeScope) { - // Remove from stack (should be the last element, but handle edge cases) - val idx = activeScopeStack.lastIndexOf(scope) - if (idx >= 0) { - activeScopeStack.removeAt(idx) - } + stateRegistry.endScope(composition, generation, scope) } override fun onScopeDisposed(scope: RecomposeScope) { - scopeToStatesMap.remove(scope) + stateRegistry.scopeDisposed(composition, generation, scope) } } + + internal data class CurrentScopeSnapshot( + val scopeKey: Int? = null, + val triggerStateObjects: Set? = null, + val hasPreciseMapping: Boolean = false, + val isForcedRecomposition: Boolean = false + ) } diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistry.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistry.kt new file mode 100644 index 000000000..4910b7783 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistry.kt @@ -0,0 +1,214 @@ +/* + * 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 + +import com.tencent.kuikly.compose.ui.createSynchronizedObject +import com.tencent.kuikly.compose.ui.getCurrentThreadId +import com.tencent.kuikly.compose.ui.synchronized + +/** + * One atomic view of the precise CompositionObserver context for the calling thread. + * + * [hasPreciseMapping] is false when the global CompositionTracer is running for a + * composition that has no matching profiler observer context on this thread. In that case the + * tracker must fall back to its coarse frame-level state changes instead of borrowing another + * composition's scope. + */ +internal data class ProfilerScopeSnapshot( + val scope: Scope? = null, + val triggerStateObjects: Set? = null, + val hasPreciseMapping: Boolean = false, + val isForcedRecomposition: Boolean = false +) + +/** Result of starting a new observation pass for one composition. */ +internal data class ProfilerCompositionBeginResult( + val generation: Long, + val handlesToDispose: List +) + +/** + * Thread-safe ownership registry used by [ProfilerCompositionObserver]. + * + * A single profiler tracker is shared by every live Compose scene, while scene recomposition can + * execute concurrently on different Kuikly threads and the global CompositionTracer can also see + * Android compositions on the main thread. Therefore observer state must be isolated by both: + * + * - composition: one pass may only replace/dispose its own scope map and handles; + * - execution thread: a trace callback may only read the active scope from its own thread. + * + * All mutable state below is protected by [lock]. Handle disposal intentionally happens at the + * caller after the handles have been detached under the lock, because dispose can synchronously + * re-enter observer callbacks. + */ +internal class ProfilerCompositionStateRegistry< + CompositionKey : Any, + Scope : Any, + Handle : Any +>( + private val currentThreadId: () -> Long = ::getCurrentThreadId +) { + + private data class CompositionState( + val generation: Long, + val scopeToStates: MutableMap?>, + val handles: MutableList = mutableListOf() + ) + + private data class ActiveScope( + val composition: CompositionKey, + val generation: Long, + val scope: Scope + ) + + private val lock = createSynchronizedObject() + private val statesByComposition = mutableMapOf>() + private val activeScopesByThread = mutableMapOf>>() + private var nextGeneration = 0L + + fun beginComposition( + composition: CompositionKey, + invalidationMap: Map?> + ): ProfilerCompositionBeginResult = synchronized(lock) { + val handlesToDispose = statesByComposition.remove(composition)?.handles?.toList().orEmpty() + removeCompositionScopesLocked(composition) + + nextGeneration += 1 + val generation = nextGeneration + val scopeSnapshot = mutableMapOf?>() + for ((scope, states) in invalidationMap) { + scopeSnapshot[scope] = states?.toSet() + } + statesByComposition[composition] = CompositionState( + generation = generation, + scopeToStates = scopeSnapshot + ) + ProfilerCompositionBeginResult(generation, handlesToDispose) + } + + /** + * Registers a handle only if its observation pass is still current. + * + * A false result means the composition ended or restarted while `scope.observe(...)` was + * creating the handle; the caller must dispose that rejected handle outside the lock. + */ + fun registerHandle(composition: CompositionKey, generation: Long, handle: Handle): Boolean = + synchronized(lock) { + val state = statesByComposition[composition] + if (state == null || state.generation != generation) { + false + } else { + state.handles.add(handle) + true + } + } + + /** Ends only [composition]'s current pass and returns detached handles for lock-free disposal. */ + fun endComposition(composition: CompositionKey): List = synchronized(lock) { + val state = statesByComposition.remove(composition) + removeCompositionScopesLocked(composition) + state?.handles?.toList().orEmpty() + } + + /** Detaches every live pass during profiler stop; callers dispose the returned handles. */ + fun disposeAll(): List = synchronized(lock) { + val handles = statesByComposition.values.flatMap { it.handles } + statesByComposition.clear() + activeScopesByThread.clear() + handles + } + + fun beginScope(composition: CompositionKey, generation: Long, scope: Scope) { + synchronized(lock) { + val state = statesByComposition[composition] + if (state == null || state.generation != generation) return + activeScopesByThread + .getOrPut(currentThreadId()) { mutableListOf() } + .add(ActiveScope(composition, generation, scope)) + } + } + + fun endScope(composition: CompositionKey, generation: Long, scope: Scope) { + synchronized(lock) { + val threadId = currentThreadId() + val stack = activeScopesByThread[threadId] ?: return + val index = stack.indexOfLast { + it.composition == composition && it.generation == generation && it.scope == scope + } + if (index >= 0) { + stack.removeAt(index) + } + if (stack.isEmpty()) { + activeScopesByThread.remove(threadId) + } + } + } + + fun scopeDisposed(composition: CompositionKey, generation: Long, scope: Scope) { + synchronized(lock) { + val state = statesByComposition[composition] + if (state != null && state.generation == generation) { + state.scopeToStates.remove(scope) + } + removeScopeLocked(composition, generation, scope) + } + } + + /** Returns one atomic current-thread snapshot, never another thread's active scope. */ + fun currentScopeSnapshot(): ProfilerScopeSnapshot = synchronized(lock) { + val activeScope = activeScopesByThread[currentThreadId()]?.lastOrNull() + ?: return@synchronized ProfilerScopeSnapshot() + val state = statesByComposition[activeScope.composition] + if (state == null || state.generation != activeScope.generation) { + return@synchronized ProfilerScopeSnapshot() + } + + val hasScopeMapping = state.scopeToStates.containsKey(activeScope.scope) + // Values were defensively copied when the pass began and are never mutated afterwards. + // Returning that immutable snapshot avoids allocating another Set for every trace callback. + val triggerStateObjects = state.scopeToStates[activeScope.scope] + ProfilerScopeSnapshot( + scope = activeScope.scope, + triggerStateObjects = triggerStateObjects, + hasPreciseMapping = true, + isForcedRecomposition = hasScopeMapping && triggerStateObjects == null + ) + } + + private fun removeCompositionScopesLocked(composition: CompositionKey) { + val emptyThreads = mutableListOf() + for ((threadId, stack) in activeScopesByThread) { + stack.removeAll { it.composition == composition } + if (stack.isEmpty()) emptyThreads.add(threadId) + } + for (threadId in emptyThreads) { + activeScopesByThread.remove(threadId) + } + } + + private fun removeScopeLocked(composition: CompositionKey, generation: Long, scope: Scope) { + val emptyThreads = mutableListOf() + for ((threadId, stack) in activeScopesByThread) { + stack.removeAll { + it.composition == composition && it.generation == generation && it.scope == scope + } + if (stack.isEmpty()) emptyThreads.add(threadId) + } + for (threadId in emptyThreads) { + activeScopesByThread.remove(threadId) + } + } +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileModuleRegistry.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileModuleRegistry.kt new file mode 100644 index 000000000..84a31446d --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileModuleRegistry.kt @@ -0,0 +1,47 @@ +/* + * 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.profiler + +import com.tencent.kuikly.core.module.FileModule + +/** + * Tracks the live page-owned [FileModule] instances available to the process-wide profiler. + * + * FileModule is created per Pager, while [RecompositionProfiler] is process-wide. A profiler + * session therefore must not permanently bind its output to whichever Pager happened to be the + * first lifecycle listener during start. The most recently registered live module is preferred; + * removing it falls back to the previous live module. + * + * The caller owns synchronization. [RecompositionProfiler] invokes every method under its lock. + */ +internal class ProfilerFileModuleRegistry { + private val modules = mutableListOf() + + fun register(module: FileModule): FileModule { + modules.removeAll { it === module } + modules.add(module) + return module + } + + fun unregister(module: FileModule): FileModule? { + modules.removeAll { it === module } + return modules.lastOrNull() + } + + fun current(): FileModule? = modules.lastOrNull() + + internal fun size(): Int = modules.size +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionOutputStrategy.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionOutputStrategy.kt index 866830f22..dc4ba13d3 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionOutputStrategy.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionOutputStrategy.kt @@ -45,3 +45,8 @@ interface RecompositionOutputStrategy { */ fun onReset() {} } + +/** Internal extension for output strategies whose persisted data embeds tracker session identity. */ +internal interface RecompositionSessionOutputStrategy { + fun onSessionReset(sessionId: String, startTimestampMs: Long) +} diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfiler.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfiler.kt index e2203ae0d..9ca8080b5 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfiler.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfiler.kt @@ -90,28 +90,42 @@ object RecompositionProfiler { } } - /** 最近一次传入的 FileModule,供 start() 重新 activate 使用 */ - private var lastFileModule: FileModule? = null + /** + * FileModule 属于 Pager,Profiler 却是进程级单例。这里跟踪所有 live Pager module, + * 当当前 Pager 销毁时回退到另一个 live module,避免文件 I/O 因 scheduler cancel 静默丢失。 + */ + private val fileModuleRegistry = ProfilerFileModuleRegistry() + + @Volatile + private var currentFileModule: FileModule? = null /** - * 由 ComposeContainer 在 onProfilerStarted 时传入 FileModule 实例。 - * 如果 enableFile=true 且尚未创建 FileOutputStrategy,则自动创建并注册。 + * 由 ComposeContainer 在 onProfilerStarted / Pager 激活时注册 FileModule 实例。 */ - internal fun setFileModule(fileModule: FileModule) { + internal fun registerFileModule(fileModule: FileModule) { synchronized(lock) { - lastFileModule = fileModule - if (config.enableFile && fileStrategy == null) { - val strategy = FileOutputStrategy(fileModule) - fileStrategy = strategy - tracker?.addOutputStrategy(strategy) - strategy.activate(tracker?.sessionId ?: "", tracker?.startTimestampMs ?: 0L) - } + currentFileModule = fileModuleRegistry.register(fileModule) + fileStrategy?.onFileModuleChanged() } } - /** FileOutputStrategy 持有,stop 时写报告 */ + /** Pager 销毁前解绑其 FileModule,并切换到另一个 live Pager。 */ + internal fun unregisterFileModule(fileModule: FileModule) { + synchronized(lock) { + currentFileModule = fileModuleRegistry.unregister(fileModule) + fileStrategy?.onFileModuleChanged() + } + } + + /** + * FileOutputStrategy 跨 stop/start 保留,使已提交的旧 session 写入与新 session + * header/clear 共用一条串行队列,不会因原生异步回调乱序覆盖。 + */ private var fileStrategy: FileOutputStrategy? = null + /** Whether the current or most recently stopped session was started with file output enabled. */ + private var sessionFileOutputEnabled: Boolean = false + /** * 内部追踪引擎实例,供 [BaseComposeScene] 帧追踪使用。 * 仅在 [isEnabled] 为 true 时非空。 @@ -215,7 +229,7 @@ object RecompositionProfiler { synchronized(lock) { if (!isEnabled) { stoppedTracker = null // 清除上次 stop 的快照 - fileStrategy = null // 清除上次的文件策略 + sessionFileOutputEnabled = config.enableFile val newTracker = RecompositionTracker() newTracker.start(config) tracker = newTracker @@ -233,19 +247,16 @@ object RecompositionProfiler { overlayStrategy = strategy newTracker.addOutputStrategy(strategy) } - // Notify lifecycle listeners to register CompositionObserver - // ComposeContainer 会在 onProfilerStarted 里调用 setFileModule + // Notify lifecycle listeners to register CompositionObserver and their live FileModule. for (listener in lifecycleListeners) { listener.onProfilerStarted(newTracker) } - // 如果页面已存活(不会再触发 onProfilerStarted),用上次缓存的 FileModule 直接 activate - if (config.enableFile && fileStrategy == null) { - lastFileModule?.let { fm -> - val strategy = FileOutputStrategy(fm) - fileStrategy = strategy - newTracker.addOutputStrategy(strategy) - strategy.activate(newTracker.sessionId, newTracker.startTimestampMs) - } + if (config.enableFile) { + val strategy = fileStrategy ?: FileOutputStrategy( + fileModuleProvider = { currentFileModule } + ).also { fileStrategy = it } + newTracker.addOutputStrategy(strategy) + strategy.activate(newTracker.sessionId, newTracker.startTimestampMs) } } } @@ -258,6 +269,28 @@ object RecompositionProfiler { */ @OptIn(InternalComposeTracingApi::class) fun stop() { + stopInternal(completion = null) + } + + /** + * Stops tracing and reports the terminal native file-output result. + * + * [completion] is invoked exactly once. [RecompositionProfilerFileOutputResult.Success] means + * the report write and every queued frame operation before it completed for the same session; + * enqueueing the write is not success. + */ + @OptIn(InternalComposeTracingApi::class) + fun stop(completion: (RecompositionProfilerFileOutputResult) -> Unit) { + stopInternal(completion) + } + + @OptIn(InternalComposeTracingApi::class) + private fun stopInternal( + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ) { + var outputStrategy: FileOutputStrategy? = null + var outputReport: RecompositionReport? = null + var immediateResult: RecompositionProfilerFileOutputResult? = null synchronized(lock) { if (isEnabled) { isEnabled = false @@ -275,10 +308,35 @@ object RecompositionProfiler { overlayStrategy = null // 写聚合报告文件 val report = stoppedTracker?.generateReport() ?: RecompositionReport.EMPTY - fileStrategy?.deactivate(report) - fileStrategy = null + if (sessionFileOutputEnabled) { + outputStrategy = fileStrategy + outputReport = report + if (outputStrategy == null && completion != null) { + immediateResult = RecompositionProfilerFileOutputResult.Failure( + report.sessionId, + "profiler file output strategy is unavailable" + ) + } + } else if (completion != null) { + immediateResult = RecompositionProfilerFileOutputResult.Failure( + report.sessionId, + "profiler file output is disabled for this session" + ) + } + } else if (completion != null) { + immediateResult = RecompositionProfilerFileOutputResult.Failure( + stoppedTracker?.sessionId.orEmpty(), + "profiler is not running" + ) } } + val strategy = outputStrategy + val report = outputReport + if (strategy != null && report != null) { + strategy.deactivate(report, completion) + } else { + immediateResult?.let { result -> notifyFileOutputCompletion(completion, result) } + } } /** @@ -287,15 +345,33 @@ object RecompositionProfiler { * 如果从未启动过,返回空报告。 * * @param saveToFile 是否同时将报告写入 profiler_report.json 并触发各输出策略的 onReportReady 回调。 - * 需要 enableFile=true 且 Profiler 正在运行(stop 后 fileStrategy 已释放)。默认 true。 + * 需要 enableFile=true。stop 后仍可重新导出最后一次快照。默认 true。 * 设为 false 时静默返回数据,不产生任何日志或文件 I/O。 */ - fun getReport(saveToFile: Boolean = true): RecompositionReport { + fun getReport(saveToFile: Boolean = true): RecompositionReport = + getReportInternal(saveToFile, completion = null) + + /** + * Gets the current report and acknowledges its terminal native file commit. + * + * This overload is intended for callers that must not display an export success message until + * the native report file is durable. [completion] is invoked exactly once. + */ + fun getReport( + saveToFile: Boolean, + completion: (RecompositionProfilerFileOutputResult) -> Unit + ): RecompositionReport = getReportInternal(saveToFile, completion) + + private fun getReportInternal( + saveToFile: Boolean, + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ): RecompositionReport { data class Snapshot( val baseReport: RecompositionReport, val trackerRef: RecompositionTracker?, val namesSnapshot: List, - val prefixesSnapshot: List + val prefixesSnapshot: List, + val fileOutputEnabled: Boolean ) val snapshot = synchronized(lock) { val t = tracker ?: stoppedTracker @@ -303,7 +379,8 @@ object RecompositionProfiler { baseReport = t?.generateReport() ?: RecompositionReport.EMPTY, trackerRef = t, namesSnapshot = excludedNames.toList().sorted(), - prefixesSnapshot = excludedPrefixes.toList().sorted() + prefixesSnapshot = excludedPrefixes.toList().sorted(), + fileOutputEnabled = sessionFileOutputEnabled ) } // 根据 excludedNames / excludedPrefixes 过滤 composables 和 hotspots @@ -330,13 +407,58 @@ object RecompositionProfiler { filteredPrefixes = snapshot.prefixesSnapshot ) if (saveToFile) { - fileStrategy?.writeReport(finalReport) + if (snapshot.fileOutputEnabled) { + val strategy = fileStrategy + if (strategy != null) { + strategy.writeReport(finalReport, completion) + } else { + notifyFileOutputCompletion( + completion, + RecompositionProfilerFileOutputResult.Failure( + finalReport.sessionId, + "profiler file output strategy is unavailable" + ) + ) + } + } else { + notifyFileOutputCompletion( + completion, + RecompositionProfilerFileOutputResult.Failure( + finalReport.sessionId, + "profiler file output is disabled for this session" + ) + ) + } // 触发所有策略的 onReportReady(日志输出等);saveToFile=false 时静默 snapshot.trackerRef?.notifyReportReady(finalReport) + } else { + notifyFileOutputCompletion( + completion, + RecompositionProfilerFileOutputResult.Failure( + finalReport.sessionId, + "file output acknowledgement requires saveToFile=true" + ) + ) } return finalReport } + private fun notifyFileOutputCompletion( + completion: ((RecompositionProfilerFileOutputResult) -> Unit)?, + result: RecompositionProfilerFileOutputResult + ) { + val callback = completion ?: return + try { + callback(result) + } catch (throwable: Throwable) { + com.tencent.kuikly.core.log.KLog.e( + "RCProfiler", + "File output completion callback failed: " + + (throwable.message ?: throwable::class.simpleName.orEmpty()) + ) + } + } + /** * 重置已采集的所有数据,从零开始统计。 * 仅在 Profiler 启用时有效。 diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfilerFileOutputResult.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfilerFileOutputResult.kt new file mode 100644 index 000000000..aeda2e6f4 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/RecompositionProfilerFileOutputResult.kt @@ -0,0 +1,37 @@ +/* + * 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.profiler + +/** + * Terminal result of persisting a profiler report and every file operation queued before it. + * + * A callback receiving [Success] is the acknowledgement that the native report write completed + * for [sessionId]. Enqueueing a write is deliberately not considered success. [Failure] includes + * native terminal errors, exhausted retryable failures, incomplete earlier frame operations, and + * a session being superseded before its report commits. + */ +sealed class RecompositionProfilerFileOutputResult { + abstract val sessionId: String + + data class Success( + override val sessionId: String + ) : RecompositionProfilerFileOutputResult() + + data class Failure( + override val sessionId: String, + val reason: String + ) : RecompositionProfilerFileOutputResult() +} 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..6a2c23460 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,9 @@ 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.getCurrentThreadId +import com.tencent.kuikly.compose.ui.synchronized import com.tencent.kuikly.core.datetime.DateTime import kotlin.concurrent.Volatile import kotlin.random.Random @@ -33,7 +36,8 @@ import kotlin.random.Random * - 采样率控制 * - 生成分析报告 * - * 本类非线程安全,由 [RecompositionProfiler] 负责同步。 + * Lifecycle mutations are serialized by [RecompositionProfiler]. Runtime callbacks can arrive on + * multiple composition/Snapshot threads, so callback-owned mutable state is synchronized here. */ internal class RecompositionTracker { @@ -54,7 +58,7 @@ internal class RecompositionTracker { @Volatile private var config: RecompositionConfig = RecompositionConfig.DEFAULT - /** 当前帧内的重组计数 */ + /** 当前帧内的重组计数;与 [composableAccumulator] 共享同一把锁。 */ private var currentFrameRecomposedCount: Int = 0 /** 事件缓冲区,使用 ArrayDeque 保证 removeFirst() 为 O(1) */ @@ -66,7 +70,8 @@ internal class RecompositionTracker { /** 实际有重组事件并被 flush 的帧计数(用于 Report.totalFrames) */ private var flushedFrameCounter: Long = 0L - /** 当前帧是否被采样 */ + /** 当前帧是否被采样。Tracer callbacks use this as a fast cross-thread admission check. */ + @Volatile private var currentFrameSampled: Boolean = true /** 追踪开始时间 */ @@ -94,6 +99,7 @@ internal class RecompositionTracker { * 因此条目数 = 页面中不同 Composable 函数的数量(通常几十个),不会无限增长。 */ private val composableAccumulator = mutableMapOf() + private val composableAccumulatorLock = createSynchronizedObject() /** * Apply callback 中 formatState() 结果的缓存。 @@ -104,7 +110,9 @@ internal class RecompositionTracker { * traceEventEnd 精确路径查此缓存,避免依赖已被覆盖的 prevValue。 * 每帧结束时(onFrameEnd)清空。 */ + /** apply 线程写入、context 线程读取,与 events 同族共享,须加锁 */ private val stateChangeCache = mutableMapOf() + private val stateChangeCacheLock = createSynchronizedObject() /** * State 身份注册表。 @@ -116,28 +124,26 @@ internal class RecompositionTracker { */ internal val stateIdentityRegistry = StateIdentityRegistry() - /** CompositionTracer 追踪栈,记录嵌套的 Composable 调用 */ - private val traceStack = mutableListOf() + /** CompositionTracer nesting is owned by the execution thread that receives the callbacks. */ + private val tracerStatesByThread = mutableMapOf() + private val traceStackLock = createSynchronizedObject() /** - * Overlay 子树过滤深度计数器。 - * 当 traceEventStart 检测到当前 info 属于 Overlay 内部 composable(匹配 overlayPrefixes)时, - * 计数器递增;其所有子 composable 的 traceStart/End 也不做任何记录,直到对应的 traceEnd 将计数器恢复。 - * 这从根本上阻断了 Overlay 重组 → 记录事件 → dataVersion++ → 触发 Overlay 重组的无限循环。 + * events 缓冲区锁。events 会在多个线程被读写: + * Kuikly context 线程(onFrameEnd/flush)与 Snapshot apply 线程(apply observer 内 + * addEvent/flush),不加锁时 flushCurrentFrameEvents 的 subList 快照会抛 + * ConcurrentModificationException(Hands 280564ed)。 */ - private var overlayFilterDepth: Int = 0 + private val eventsLock = createSynchronizedObject() /** * CompositionObserver 实例,用于精确的 scope→state 重组原因追踪。 * 通过 [BaseComposeScene] 注册到 Composition 实例上。 */ internal val compositionObserver: ProfilerCompositionObserver by lazy { - ProfilerCompositionObserver(this) + ProfilerCompositionObserver() } - /** 当前是否有精确的 scope→state 映射可用 */ - private var hasPreciseScopeMapping: Boolean = false - /** 输出策略列表 */ private val outputStrategies = mutableListOf() @@ -245,10 +251,18 @@ internal class RecompositionTracker { val startTimeMs: Long, val dirty1: Int = 0, val dirty2: Int = 0, - /** Scope key snapshot captured at traceEventStart time. - * Prevents scope loss when a framework Composable (e.g. CompositionLocalProvider) - * is filtered out — children already captured the scope before the filter runs. */ - val scopeKeySnapshot: Int? = null + /** + * Observer context captured on the same execution thread as traceEventStart. + * + * Keeping the full immutable snapshot with the entry prevents a later end callback from + * combining one thread's scope/state mapping with another thread's popped composable. + */ + val observerSnapshot: ProfilerCompositionObserver.CurrentScopeSnapshot + ) + + private class TracerThreadState( + val traceStack: MutableList = mutableListOf(), + var overlayFilterDepth: Int = 0 ) /** @@ -260,10 +274,13 @@ internal class RecompositionTracker { this.sessionId = "rcp-${startTimestampMs}-${Random.nextInt(10000)}" this.frameCounter = 0L this.flushedFrameCounter = 0L - events.clear() - currentFrameStateChanges.clear() + synchronized(eventsLock) { events.clear() } + synchronized(stateChangeCacheLock) { currentFrameStateChanges.clear() } stateChangeAccumulator.clear() - composableAccumulator.clear() + synchronized(composableAccumulatorLock) { + currentFrameRecomposedCount = 0 + composableAccumulator.clear() + } stateIdentityRegistry.clear() // 初始化过滤链 @@ -286,10 +303,15 @@ internal class RecompositionTracker { * 停止追踪,释放资源。 */ fun stop() { + // Close admission before clearing the buckets. A trace start that passed the fast check + // immediately before stop must re-check under traceStackLock and may not repopulate a + // stopped tracker after the clear. + currentFrameSampled = false unregisterSnapshotObserver() - traceStack.clear() - overlayFilterDepth = 0 - hasPreciseScopeMapping = false + compositionObserver.dispose() + synchronized(traceStackLock) { + tracerStatesByThread.clear() + } filterChain = null // 清理过滤链资源 } @@ -297,19 +319,25 @@ internal class RecompositionTracker { * 重置所有采集数据。 */ fun reset() { - events.clear() + synchronized(eventsLock) { events.clear() } frameCounter = 0L flushedFrameCounter = 0L - currentFrameStateChanges.clear() - stateChangeCache.clear() + synchronized(stateChangeCacheLock) { currentFrameStateChanges.clear() } + synchronized(stateChangeCacheLock) { stateChangeCache.clear() } stateChangeAccumulator.clear() - composableAccumulator.clear() + synchronized(composableAccumulatorLock) { + currentFrameRecomposedCount = 0 + composableAccumulator.clear() + } stateIdentityRegistry.clear() startTimestampMs = DateTime.currentTimestamp() sessionId = "rcp-${startTimestampMs}-${Random.nextInt(10000)}" - // 通知所有输出策略清空自身数据 + // 通知所有输出策略清空自身数据,并传递 reset 后的新 session。 for (strategy in outputStrategies) { strategy.onReset() + if (strategy is RecompositionSessionOutputStrategy) { + strategy.onSessionReset(sessionId, startTimestampMs) + } } } @@ -374,8 +402,8 @@ internal class RecompositionTracker { currentFrameSampled = shouldSampleFrame() if (!currentFrameSampled) return false - currentFrameStateChanges.clear() - currentFrameRecomposedCount = 0 + synchronized(stateChangeCacheLock) { currentFrameStateChanges.clear() } + synchronized(composableAccumulatorLock) { currentFrameRecomposedCount = 0 } val event = RecompositionFrameStartEvent( timestampMs = DateTime.currentTimestamp(), frameId = frameCounter @@ -392,21 +420,25 @@ internal class RecompositionTracker { if (!currentFrameSampled) return val now = DateTime.currentTimestamp() - val frameStart = events.lastOrNull { it is RecompositionFrameStartEvent } as? RecompositionFrameStartEvent + val frameStart = synchronized(eventsLock) { + events.lastOrNull { it is RecompositionFrameStartEvent } + } as? RecompositionFrameStartEvent val durationMs = if (frameStart != null) now - frameStart.timestampMs else 0L + val recomposedCountSnapshot = synchronized(composableAccumulatorLock) { + currentFrameRecomposedCount.also { currentFrameRecomposedCount = 0 } + } val endEvent = RecompositionFrameEndEvent( timestampMs = now, frameId = frameCounter, durationMs = durationMs, - recomposedCount = currentFrameRecomposedCount + recomposedCount = recomposedCountSnapshot ) addEvent(endEvent) flushCurrentFrameEvents() - currentFrameRecomposedCount = 0 currentFrameSampled = false - stateChangeCache.clear() + synchronized(stateChangeCacheLock) { stateChangeCache.clear() } } /** @@ -437,18 +469,33 @@ 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 + val threadId = getCurrentThreadId() + synchronized(traceStackLock) { + if (!currentFrameSampled) { + return + } + val tracerState = tracerStatesByThread.getOrPut(threadId) { TracerThreadState() } + // If already inside an Overlay subtree, just increment depth and skip + if (tracerState.overlayFilterDepth > 0) { + tracerState.overlayFilterDepth++ + return + } + // Check if this composable is an Overlay internal (e.g. ProfilerOverlaySlot) + if (isOverlayComposable(info)) { + tracerState.overlayFilterDepth = 1 + return + } + tracerState.traceStack.add( + TraceEntry( + key = key, + info = info, + startTimeMs = DateTime.currentTimestamp(), + dirty1 = dirty1, + dirty2 = dirty2, + observerSnapshot = compositionObserver.currentScopeSnapshot() + ) + ) } - traceStack.add(TraceEntry(key, info, DateTime.currentTimestamp(), dirty1, dirty2, - scopeKeySnapshot = compositionObserver.getCurrentScopeKey())) } /** @@ -456,15 +503,31 @@ internal class RecompositionTracker { * 编译器在每个 @Composable 函数的非 skip 路径末尾调用此方法。 */ private fun onComposableTraceEnd() { - if (!currentFrameSampled) return - // If inside an Overlay subtree, just decrement depth and skip - if (overlayFilterDepth > 0) { - overlayFilterDepth-- - return + val threadId = getCurrentThreadId() + val (entry, parentInfo) = synchronized(traceStackLock) { + val tracerState = tracerStatesByThread[threadId] ?: return + // If inside an Overlay subtree, just decrement depth and skip + if (tracerState.overlayFilterDepth > 0) { + tracerState.overlayFilterDepth-- + removeTracerStateIfIdleLocked(threadId, tracerState) + return + } + if (tracerState.traceStack.isEmpty()) { + tracerStatesByThread.remove(threadId) + return + } + + val poppedEntry = tracerState.traceStack.removeAt(tracerState.traceStack.lastIndex) + val poppedParentInfo = tracerState.traceStack.lastOrNull { entry -> + extractComposableName(entry.info) != "" + }?.info + removeTracerStateIfIdleLocked(threadId, tracerState) + poppedEntry to poppedParentInfo } - if (traceStack.isEmpty()) return - val entry = traceStack.removeAt(traceStack.lastIndex) + // The start was admitted while sampled, but a concurrent frame stop can close sampling + // before this matching end. Always pop ownership above; only record into a live frame. + if (!currentFrameSampled) return // 根据过滤链判断是否过滤此 Composable if (shouldFilterComposable(entry.info)) { @@ -474,10 +537,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,无具体名称,不记录也不计数 @@ -492,9 +551,10 @@ internal class RecompositionTracker { // 精确路径查 stateChangeCache(apply callback 里预缓存的 formatState 结果), // 因为此时 registry 的 prevValue 已被 updateLastSeenValue 覆盖。 // Cache miss 时降级调 formatState(显示 value= 格式)。 + val observerSnapshot = entry.observerSnapshot val triggerStates: List - if (hasPreciseScopeMapping) { - val stateObjects = compositionObserver.getCurrentScopeTriggerStateObjects() + if (observerSnapshot.hasPreciseMapping) { + val stateObjects = observerSnapshot.triggerStateObjects if (stateObjects != null) { // Register reader mappings now for (state in stateObjects) { @@ -503,14 +563,20 @@ internal class RecompositionTracker { // 查 stateChangeCache 获取 apply callback 里已格式化好的 prev→now 字符串 triggerStates = stateObjects.map { state -> val hash = com.tencent.kuikly.compose.material3.internal.identityHashCode(state) - stateChangeCache[hash] ?: stateIdentityRegistry.formatState(state) + synchronized(stateChangeCacheLock) { stateChangeCache[hash] } + ?: stateIdentityRegistry.formatState(state) } } else { - // Forced recomposition or initial composition — use sentinel from observer - triggerStates = compositionObserver.getCurrentScopeTriggerStates() ?: emptyList() + // A null mapping value means a forced recomposition. An absent scope entry means an + // observed child/initial scope with no precise trigger states. + triggerStates = if (observerSnapshot.isForcedRecomposition) { + listOf("[forced recomposition]") + } else { + emptyList() + } } } else { - triggerStates = currentFrameStateChanges.toList() + triggerStates = synchronized(stateChangeCacheLock) { currentFrameStateChanges.toList() } } // === 参数变更检测(解析编译器 $dirty bitmask) === @@ -520,10 +586,9 @@ internal class RecompositionTracker { else -> RecompositionReason.UNKNOWN } - // === Scope key:优先使用 start 时的快照,兜底查实时栈 === - // 快照机制解决 filter 截断问题:当框架组件(如 CompositionLocalProvider)被过滤时, - // 其子节点在 traceEventStart 时已捕获到正确的 scope,不会因父节点被 filter 而丢失。 - val scopeKey = entry.scopeKeySnapshot ?: compositionObserver.getCurrentScopeKey() + // Scope/state context is frozen with the owning entry at traceEventStart. This also solves + // filter truncation: children retain their scope even if a framework parent is filtered. + val scopeKey = observerSnapshot.scopeKey val event = ComposableRecomposedEvent( timestampMs = now, @@ -540,10 +605,20 @@ internal class RecompositionTracker { // 累积统计:用 composableName + sourceLocation 作为聚合 key, // 避免不同类中同名函数(如多个 invoke)被合并为一条统计 - currentFrameRecomposedCount++ val accKey = if (sourceLocation != null) "$composableName @$sourceLocation" else composableName - val acc = composableAccumulator.getOrPut(accKey) { MutableComposableAccumulator(accKey) } - acc.recordRecomposition(durationMs, triggerStates, reason, paramChanges, scopeKey) + synchronized(composableAccumulatorLock) { + currentFrameRecomposedCount++ + val acc = composableAccumulator.getOrPut(accKey) { + MutableComposableAccumulator(accKey) + } + acc.recordRecomposition(durationMs, triggerStates, reason, paramChanges, scopeKey) + } + } + + private fun removeTracerStateIfIdleLocked(threadId: Long, tracerState: TracerThreadState) { + if (tracerState.traceStack.isEmpty() && tracerState.overlayFilterDepth == 0) { + tracerStatesByThread.remove(threadId) + } } // ========== 报告生成 ========== @@ -556,31 +631,38 @@ internal class RecompositionTracker { val durationMs = now - startTimestampMs val durationSeconds = (durationMs / 1000.0).coerceAtLeast(0.001) - val composableStatsList = composableAccumulator.values.map { acc -> - val recompositionsPerSecond = acc.count / durationSeconds - // acc.name 是 "composableName @sourceLocation" 格式的聚合 key, - // 拆分出短名和源码位置分别填入 ComposableStats - val sepIdx = acc.name.indexOf(" @") - val (shortName, srcLoc) = if (sepIdx > 0) { - acc.name.substring(0, sepIdx) to acc.name.substring(sepIdx + 2) - } else { - acc.name to null - } - ComposableStats( - name = shortName, - recompositionCount = acc.count, - totalDurationMs = acc.totalDurationMs, - avgDurationMs = if (acc.count > 0) acc.totalDurationMs.toDouble() / acc.count else 0.0, - maxDurationMs = acc.maxDurationMs, - minDurationMs = acc.minDurationMs, - triggerStates = acc.allTriggerStates.toSet(), - isHotspot = recompositionsPerSecond > config.hotspotThreshold, - paramChangeFrequency = acc.paramChangeFrequency.toMap(), - sourceLocation = srcLoc, - scopeDistribution = acc.scopeDistribution.toMap(), - noScopeRecompositions = acc.noScopeCount - ) - }.sortedByDescending { it.recompositionCount } + val (composableStatsList, totalRecompositions) = synchronized(composableAccumulatorLock) { + val stats = composableAccumulator.values.map { acc -> + val recompositionsPerSecond = acc.count / durationSeconds + // acc.name 是 "composableName @sourceLocation" 格式的聚合 key, + // 拆分出短名和源码位置分别填入 ComposableStats + val sepIdx = acc.name.indexOf(" @") + val (shortName, srcLoc) = if (sepIdx > 0) { + acc.name.substring(0, sepIdx) to acc.name.substring(sepIdx + 2) + } else { + acc.name to null + } + ComposableStats( + name = shortName, + recompositionCount = acc.count, + totalDurationMs = acc.totalDurationMs, + avgDurationMs = if (acc.count > 0) { + acc.totalDurationMs.toDouble() / acc.count + } else { + 0.0 + }, + maxDurationMs = acc.maxDurationMs, + minDurationMs = acc.minDurationMs, + triggerStates = acc.allTriggerStates.toSet(), + isHotspot = recompositionsPerSecond > config.hotspotThreshold, + paramChangeFrequency = acc.paramChangeFrequency.toMap(), + sourceLocation = srcLoc, + scopeDistribution = acc.scopeDistribution.toMap(), + noScopeRecompositions = acc.noScopeCount + ) + }.sortedByDescending { it.recompositionCount } + stats to composableAccumulator.values.sumOf { it.count } + } val hotspots = composableStatsList.filter { it.isHotspot } @@ -598,31 +680,13 @@ internal class RecompositionTracker { startTimestampMs = startTimestampMs, durationMs = durationMs, totalFrames = flushedFrameCounter, - totalRecompositions = composableAccumulator.values.sumOf { it.count }, + totalRecompositions = totalRecompositions, composables = composableStatsList, hotspots = hotspots, stateChanges = stateChangeRecords ) } - // ========== CompositionObserver 回调 ========== - - /** - * 由 [ProfilerCompositionObserver.onBeginComposition] 调用。 - * 通知 tracker:本次组合的精确 scope→state 映射已就绪。 - */ - internal fun onCompositionObserverBegin() { - hasPreciseScopeMapping = true - } - - /** - * 由 [ProfilerCompositionObserver.onEndComposition] 调用。 - * 清理精确映射标记。 - */ - internal fun onCompositionObserverEnd() { - hasPreciseScopeMapping = false - } - // ========== 内部方法 ========== /** @@ -636,19 +700,24 @@ internal class RecompositionTracker { * the first flush, the second call finds no FrameStartEvent and outputs nothing. */ private fun flushCurrentFrameEvents() { - val lastStartIndex = events.indexOfLast { it is RecompositionFrameStartEvent } - if (lastStartIndex < 0) return - val frameEvents = events.subList(lastStartIndex, events.size).toList() + // 锁内只取快照并移除已 flush 事件;strategy 回调放锁外,避免回调里 + // 再进 tracker(如同帧二次 flush / 写文件)造成重入死锁。 + val frameEvents = synchronized(eventsLock) { + val lastStartIndex = events.indexOfLast { it is RecompositionFrameStartEvent } + if (lastStartIndex < 0) return + val snapshot = events.subList(lastStartIndex, events.size).toList() + // Remove flushed events so a second flush call for the same frame outputs nothing + while (events.size > lastStartIndex) { + events.removeLast() + } + snapshot + } if (frameEvents.any { it is ComposableRecomposedEvent }) { flushedFrameCounter++ for (strategy in outputStrategies) { strategy.onFrameComplete(frameEvents) } } - // Remove flushed events so a second flush call for the same frame outputs nothing - while (events.size > lastStartIndex) { - events.removeLast() - } } private fun shouldSampleFrame(): Boolean { @@ -658,10 +727,12 @@ internal class RecompositionTracker { } private fun addEvent(event: RecompositionEvent) { - events.addLast(event) - // 缓冲区溢出时丢弃最旧事件(O(1)) - while (events.size > config.maxEventBufferSize) { - events.removeFirst() + synchronized(eventsLock) { + events.addLast(event) + // 缓冲区溢出时丢弃最旧事件(O(1)) + while (events.size > config.maxEventBufferSize) { + events.removeFirst() + } } } @@ -687,7 +758,7 @@ internal class RecompositionTracker { // 缓存格式化结果,供后续 traceEventEnd 精确路径使用 val hash = com.tencent.kuikly.compose.material3.internal.identityHashCode(obj) - stateChangeCache[hash] = stateKey + synchronized(stateChangeCacheLock) { stateChangeCache[hash] = stateKey } } // Update lastSeen value for each changed state after formatting, // so next apply can show the correct prev value. @@ -697,18 +768,23 @@ internal class RecompositionTracker { // Flush any recomposition events that were captured during sub-compositions // (e.g. LazyColumn items in nested scenes) that don't have their own onFrameEnd. // At this point all traceEventStart/End calls for this apply batch are complete. - if (currentFrameRecomposedCount > 0 && currentFrameSampled) { + val recomposedCountSnapshot = synchronized(composableAccumulatorLock) { + currentFrameRecomposedCount + } + if (recomposedCountSnapshot > 0 && currentFrameSampled) { val now = DateTime.currentTimestamp() - val frameStart = events.lastOrNull { it is RecompositionFrameStartEvent } as? RecompositionFrameStartEvent + val frameStart = synchronized(eventsLock) { + events.lastOrNull { it is RecompositionFrameStartEvent } + } as? RecompositionFrameStartEvent val durationMs = if (frameStart != null) now - frameStart.timestampMs else 0L addEvent(RecompositionFrameEndEvent( timestampMs = now, frameId = frameCounter, durationMs = durationMs, - recomposedCount = currentFrameRecomposedCount + recomposedCount = recomposedCountSnapshot )) flushCurrentFrameEvents() - currentFrameRecomposedCount = 0 + synchronized(composableAccumulatorLock) { currentFrameRecomposedCount = 0 } currentFrameSampled = false } } @@ -749,7 +825,7 @@ internal class RecompositionTracker { private fun onStateChanged(stateKey: String) { val now = DateTime.currentTimestamp() - currentFrameStateChanges.add(stateKey) + synchronized(stateChangeCacheLock) { currentFrameStateChanges.add(stateKey) } // 已有记录的直接更新;新 key 需检查上限(防止无限积累) if (stateChangeAccumulator.containsKey(stateKey)) { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/StateIdentityRegistry.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/StateIdentityRegistry.kt index fdef59f90..8a8d8ab35 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/StateIdentityRegistry.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/StateIdentityRegistry.kt @@ -16,6 +16,8 @@ package com.tencent.kuikly.compose.profiler import com.tencent.kuikly.compose.material3.internal.identityHashCode +import com.tencent.kuikly.compose.ui.createSynchronizedObject +import com.tencent.kuikly.compose.ui.synchronized /** * State 身份注册表。 @@ -24,10 +26,15 @@ import com.tencent.kuikly.compose.material3.internal.identityHashCode * - 上一次已知的 value(prevValue),用于输出 "prev=x, now=y" 格式 * - 读取该 State 的 Composable 名称集合(readers) * - * 本类非线程安全,由 [RecompositionTracker] 负责同步。 + * 线程安全:所有公共方法内部加锁([lock])。调用方包括 Kuikly context 线程 + * (traceEventEnd 精确路径)与 Snapshot apply 线程(apply observer), + * 此前依赖 RecompositionTracker 同步但并未实现,导致 formatState 迭代 readers + * 时并发修改 LinkedHashMap 抛 CME(device 实证)。 */ internal class StateIdentityRegistry { + private val lock = createSynchronizedObject() + /** identity hash → 上次 apply 时记录的 value 字符串(作为下次的 prev) */ private val identityToPrevValue = mutableMapOf() @@ -42,7 +49,9 @@ internal class StateIdentityRegistry { */ fun updateLastSeenValue(state: Any) { val hash = identityHashCode(state) - identityToPrevValue[hash] = extractValue(state.toString()) + synchronized(lock) { + identityToPrevValue[hash] = extractValue(state.toString()) + } } /** @@ -53,7 +62,9 @@ internal class StateIdentityRegistry { */ fun recordReader(state: Any, composableName: String) { val hash = identityHashCode(state) - identityToReaders.getOrPut(hash) { mutableSetOf() }.add(composableName) + synchronized(lock) { + identityToReaders.getOrPut(hash) { mutableSetOf() }.add(composableName) + } } /** @@ -69,9 +80,11 @@ internal class StateIdentityRegistry { */ fun formatState(state: Any): String { val hash = identityHashCode(state) - val prevValue = identityToPrevValue[hash] + // 锁内取快照,锁外格式化(避免迭代 readers 时被并发修改) + val (prevValue, readers) = synchronized(lock) { + (identityToPrevValue[hash]) to (identityToReaders[hash]?.toSet()) + } val nowValue = extractValue(state.toString()) - val readers = identityToReaders[hash] return buildString { append("State(") @@ -100,8 +113,9 @@ internal class StateIdentityRegistry { */ fun formatStateFromCache(state: Any, stateChangeCache: Map>): String { val hash = identityHashCode(state) - val readers = identityToReaders[hash] - val cached = stateChangeCache[hash] + val (readers, cached) = synchronized(lock) { + (identityToReaders[hash]?.toSet()) to (stateChangeCache[hash]) + } return buildString { append("State(") @@ -130,8 +144,10 @@ internal class StateIdentityRegistry { * 清除所有注册数据。在 profiler reset 时调用。 */ fun clear() { - identityToPrevValue.clear() - identityToReaders.clear() + synchronized(lock) { + identityToPrevValue.clear() + identityToReaders.clear() + } } /** diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/FileOutputStrategy.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/FileOutputStrategy.kt index d8bd37706..0e28b1218 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/FileOutputStrategy.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/profiler/output/FileOutputStrategy.kt @@ -15,16 +15,123 @@ package com.tencent.kuikly.compose.profiler.output +import com.tencent.kuikly.compose.coroutines.internal.KuiklyContextScheduler import com.tencent.kuikly.compose.profiler.ComposableRecomposedEvent import com.tencent.kuikly.compose.profiler.RecompositionEvent 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.RecompositionProfilerFileOutputResult import com.tencent.kuikly.compose.profiler.RecompositionReport +import com.tencent.kuikly.compose.profiler.RecompositionSessionOutputStrategy import com.tencent.kuikly.compose.profiler.ScrollContextEvent import com.tencent.kuikly.compose.profiler.TouchContextEvent +import com.tencent.kuikly.compose.ui.createSynchronizedObject +import com.tencent.kuikly.compose.ui.synchronized import com.tencent.kuikly.core.datetime.DateTime import com.tencent.kuikly.core.module.FileModule +import kotlin.concurrent.Volatile +import kotlinx.atomicfu.atomic + +internal enum class ProfilerFileOperationKind { + WRITE, + APPEND +} + +private val profilerFileOperationProcessEpoch = DateTime.currentTimestamp() +private val profilerFileOperationSequence = atomic(0L) + +private fun nextProfilerFileOperationId(): String = + "profiler-$profilerFileOperationProcessEpoch-${profilerFileOperationSequence.getAndIncrement()}" + +internal class ProfilerFileOperation( + val kind: ProfilerFileOperationKind, + val filename: String, + val content: String, + val generation: Long, + val sessionId: String, + val operationId: String = nextProfilerFileOperationId(), + val completion: ((RecompositionProfilerFileOutputResult) -> Unit)? = null +) { + /** Same-module retries are deliberately bounded so a synchronous native failure cannot spin. */ + internal var sameModuleRetryCount: Int = 0 + + /** Guarded by FileOutputStrategy.fileOperationsLock. */ + internal var completionDelivered: Boolean = false +} + +internal sealed class ProfilerFileIoResult { + internal object Success : ProfilerFileIoResult() + internal data class RetryableFailure(val reason: String) : ProfilerFileIoResult() + internal data class TerminalFailure(val reason: String) : ProfilerFileIoResult() +} + +internal fun interface ProfilerFileIoDispatcher { + fun dispatch( + module: FileModule, + operation: ProfilerFileOperation, + completion: (ProfilerFileIoResult) -> Unit + ) +} + +private object NativeProfilerFileIoDispatcher : ProfilerFileIoDispatcher { + override fun dispatch( + module: FileModule, + operation: ProfilerFileOperation, + completion: (ProfilerFileIoResult) -> Unit + ) { + val invokeNative = { + val callback: (com.tencent.kuikly.core.nvi.serialization.json.JSONObject?) -> Unit = { result -> + val error = result?.optString("error").orEmpty() + val path = result?.optString("path").orEmpty() + completion( + when { + error == "context unavailable" -> ProfilerFileIoResult.RetryableFailure(error) + error.isNotEmpty() -> ProfilerFileIoResult.TerminalFailure(error) + path.isNotEmpty() -> ProfilerFileIoResult.Success + else -> ProfilerFileIoResult.TerminalFailure("native completion missing path") + } + ) + } + try { + when (operation.kind) { + ProfilerFileOperationKind.WRITE -> + module.writeFile( + operation.filename, + operation.content, + operation.operationId, + callback + ) + ProfilerFileOperationKind.APPEND -> + module.appendFile( + operation.filename, + operation.content, + operation.operationId, + callback + ) + } + } catch (throwable: Throwable) { + completion( + ProfilerFileIoResult.TerminalFailure( + throwable.message ?: throwable::class.simpleName.orEmpty().ifEmpty { "unknown error" } + ) + ) + } + } + + if (KuiklyContextScheduler.isOnKuiklyThread(module.pagerId)) { + invokeNative() + } else { + KuiklyContextScheduler.runOnKuiklyThread(module.pagerId) { cancel -> + if (cancel) { + completion(ProfilerFileIoResult.RetryableFailure("pager bridge unavailable")) + } else { + invokeNative() + } + } + } + } +} /** * 文件写入输出策略。 @@ -37,36 +144,72 @@ import com.tencent.kuikly.core.module.FileModule * - profiler_frames.jsonl 第一行为 session header:{"type":"session","sessionId":"...","startTimestampMs":...} * 后续每行一个帧 JSON,可按 sessionId 过滤跨 session 数据 * - * 多页面场景:同 App 内多个页面共享同一 FileModule 目录,同名文件后写覆盖前写。 + * 多页面场景:同 App 内多个页面共享同一原生目录,但 FileModule 本身属于 Pager。 + * 每次 I/O 从 [fileModuleProvider] 解析当前 live module;若 Pager 在调度窗口销毁, + * 操作保留在串行队列中,等待下一个 live module 重试,不得静默丢弃。 * - * @param fileModule KMP 层 FileModule 实例,由 ComposeContainer 传入 + * @param fileModuleProvider 返回当前 live Pager 的 FileModule + * @param ioDispatcher 生产环境调度到 Kuikly context 并调原生 FileModule;测试可注入 fake */ internal class FileOutputStrategy( - private val fileModule: FileModule -) : RecompositionOutputStrategy { + private val fileModuleProvider: () -> FileModule?, + private val ioDispatcher: ProfilerFileIoDispatcher = NativeProfilerFileIoDispatcher +) : RecompositionOutputStrategy, RecompositionSessionOutputStrategy { + + private data class CompletionDelivery( + val callback: (RecompositionProfilerFileOutputResult) -> Unit, + val result: RecompositionProfilerFileOutputResult + ) + + private data class SessionSnapshot( + val generation: Long, + val sessionId: String + ) + + private data class PendingFrameBatch( + val generation: Long, + val sessionId: String, + val content: String + ) companion object { private const val FILE_FRAMES = "profiler_frames.jsonl" private const val FILE_REPORT = "profiler_report.json" /** append 批量写入间隔(毫秒) */ private const val APPEND_INTERVAL_MS = 2000L + /** Initial attempt plus two retries; exhaustion is surfaced instead of stalling forever. */ + internal const val MAX_SAME_MODULE_RETRIES = 2 } - /** 待 append 的帧 JSON 缓冲区 */ + /** 待 append 的帧 JSON 缓冲区(onFrameComplete 在帧路径线程写入,须加锁) */ private val pendingFrames = mutableListOf() + private val pendingFramesLock = createSynchronizedObject() + + /** FileModule 是异步接口;所有 write/append 必须串行,防止 header/clear/report 与 append 乱序。 */ + private val fileOperationsLock = createSynchronizedObject() + private val pendingFileOperations = mutableListOf() + private var inFlightFileOperation: ProfilerFileOperation? = null + private var inFlightFileModule: FileModule? = null + private var blockedFileModule: FileModule? = null + private var fileSessionGeneration: Long = 0L + private val generationArtifactFailures = mutableMapOf() /** 上次 append 的时间戳 */ private var lastAppendMs: Long = 0L - /** 当前是否处于 start/stop 之间(由外部通过 setActive 控制) */ + /** 当前是否处于 start/stop 之间(由外部通过 setActive 控制);帧路径线程读、context 线程写 */ + @Volatile private var active: Boolean = false - /** 当前 session ID,写入 frames 文件 header 用 */ - private var currentSessionId: String = "" - /** session 真正的 start 时间戳(tracker.startTimestampMs),用于过滤旧帧 */ private var sessionStartTimestampMs: Long = 0L + /** Current session id; guarded together with [sessionGeneration] by [pendingFramesLock]. */ + private var sessionId: String = "" + + /** reset/activate 代际;防止 reset 前已开始构建的帧在 reset 后进入新 session。 */ + private var sessionGeneration: Long = 0L + /** * 由 RecompositionProfiler 在 start() 时调用,激活文件写入。 * 写入 session header 行到 frames 文件(覆盖旧文件),确保每次 session 数据独立。 @@ -75,28 +218,43 @@ internal class FileOutputStrategy( * @param sessionStartMs tracker.startTimestampMs,用于过滤 start 之前的旧帧 */ fun activate(sessionId: String, sessionStartMs: Long) { - active = true - currentSessionId = sessionId - sessionStartTimestampMs = sessionStartMs - lastAppendMs = DateTime.currentTimestamp() - pendingFrames.clear() - // 写 session header,同时清空上次 session 的帧数据 - val header = "{\"type\":\"session\",\"sessionId\":\"$sessionId\",\"startTimestampMs\":$sessionStartMs}\n" - fileModule.writeFile(FILE_FRAMES, header) { } - // 同步清空 report 文件,避免旧 report 与新 frames 属于不同 session - fileModule.writeFile(FILE_REPORT, "") { } + beginSession(sessionId, sessionStartMs, activate = true) } /** * 由 RecompositionProfiler 在 stop() 时调用,停止文件写入并 flush 剩余帧数据。 */ fun deactivate(report: RecompositionReport) { - if (!active) return - active = false - // flush 剩余帧 + deactivate(report, completion = null) + } + + /** + * Deactivates capture and acknowledges the native report commit. The callback is terminal and + * is invoked exactly once. It is never invoked merely because the write was enqueued. + */ + fun deactivate( + report: RecompositionReport, + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ) { + val inactiveReason = synchronized(pendingFramesLock) { + when { + !active -> "profiler file output is not active" + sessionId != report.sessionId -> + "profiler session was superseded before stop file output began" + else -> { + active = false + null + } + } + } + if (inactiveReason != null) { + if (completion != null) { + deliverImmediateFailure(report.sessionId, inactiveReason, completion) + } + return + } flushPendingFrames() - // 写聚合报告 - writeReport(report) + enqueueReport(report, completion) } /** @@ -104,22 +262,42 @@ internal class FileOutputStrategy( * 先 flush 内存中尚未写入的帧,确保 frames 文件与 report 数据完整一致。 */ fun writeReport(report: RecompositionReport) { + writeReport(report, completion = null) + } + + /** Writes [report] and invokes [completion] only after the native report write is terminal. */ + fun writeReport( + report: RecompositionReport, + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ) { flushPendingFrames() - fileModule.writeFile(FILE_REPORT, report.toJson()) { } + enqueueReport(report, completion) } override fun onFrameComplete(events: List) { - if (!active) return + val generation = synchronized(pendingFramesLock) { + if (!active) return + sessionGeneration + } // 过滤 session start 之前产生的旧帧(多页面场景下其他页面的帧可能晚于 activate 到达) val frameStart = events.firstOrNull { it is RecompositionFrameStartEvent } as? RecompositionFrameStartEvent - if (frameStart != null && frameStart.timestampMs < sessionStartTimestampMs) return val frameJson = buildFrameJson(events) - pendingFrames.add(frameJson) - // 每 2 秒批量写入一次 val now = DateTime.currentTimestamp() - if (now - lastAppendMs >= APPEND_INTERVAL_MS) { + val shouldFlush = synchronized(pendingFramesLock) { + if (!active || generation != sessionGeneration) return@synchronized false + if (frameStart != null && frameStart.timestampMs < sessionStartTimestampMs) { + return@synchronized false + } + pendingFrames.add(frameJson) + if (now - lastAppendMs >= APPEND_INTERVAL_MS) { + lastAppendMs = now + true + } else { + false + } + } + if (shouldFlush) { flushPendingFrames() - lastAppendMs = now } } @@ -127,24 +305,368 @@ internal class FileOutputStrategy( // 由 deactivate() / writeReport() 主动调用,此处不重复写 } + override fun onSessionReset(sessionId: String, startTimestampMs: Long) { + if (!active) return + beginSession(sessionId, startTimestampMs, activate = false) + } + /** * 追加上下文事件(touch_context / scroll_context)为独立 JSONL 行到 pendingFrames 缓冲区。 * 由 RecompositionProfiler.recordTouchContext / recordScrollContext 调用。 * 非 active 状态下忽略(Profiler 未启用时零开销由调用方的 isEnabled 门控保证)。 */ internal fun appendContextEvent(event: RecompositionEvent) { - if (!active) return val json = buildContextEventJson(event) ?: return - pendingFrames.add(json) + synchronized(pendingFramesLock) { + if (active && event.timestampMs >= sessionStartTimestampMs) { + pendingFrames.add(json) + } + } + } + + /** 当 live Pager/FileModule 集合变化时,重试因旧 Pager 销毁而保留的队头操作。 */ + internal fun onFileModuleChanged() { + val currentModule = fileModuleProvider() + synchronized(fileOperationsLock) { + blockedFileModule = null + val operation = inFlightFileOperation + if (operation != null && inFlightFileModule !== currentModule) { + // Native may already have committed this operation even though the destroyed + // Pager can no longer deliver its callback. Reuse the operation and its stable + // idempotency key on the new Pager; the process-wide native queue safely dedupes + // a commit whose acknowledgement was lost. + inFlightFileOperation = null + inFlightFileModule = null + operation.sameModuleRetryCount = 0 + pendingFileOperations.add(0, operation) + } + } + dispatchNextFileOperation() } // ========== 内部方法 ========== + private fun beginSession(sessionId: String, sessionStartMs: Long, activate: Boolean) { + val header = "{\"type\":\"session\",\"sessionId\":\"$sessionId\",\"startTimestampMs\":$sessionStartMs}\n" + val superseded = mutableListOf() + synchronized(pendingFramesLock) { + if (activate) active = true + this.sessionId = sessionId + sessionStartTimestampMs = sessionStartMs + sessionGeneration += 1L + lastAppendMs = DateTime.currentTimestamp() + pendingFrames.clear() + val generation = sessionGeneration + // Publish the frame/session generation and its file-queue generation atomically. A + // concurrent export can now observe either the complete old generation or the complete + // new generation, never a new frame generation paired with the old file queue. + synchronized(fileOperationsLock) { + // 新 session 取代尚未提交的旧 session 操作。已进入原生层的操作不可取消, + // 但串行队列保证新 header/clear 必定在其完成后覆盖旧数据。 + pendingFileOperations.forEach { operation -> + takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + "profiler session superseded before file output committed" + ) + )?.let(superseded::add) + } + inFlightFileOperation?.let { operation -> + if (operation.generation != generation) { + takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + "profiler session superseded before file output committed" + ) + )?.let(superseded::add) + } + } + pendingFileOperations.clear() + fileSessionGeneration = generation + generationArtifactFailures.clear() + pendingFileOperations.add( + ProfilerFileOperation( + ProfilerFileOperationKind.WRITE, + FILE_FRAMES, + header, + generation, + sessionId + ) + ) + // 清空旧 report,避免上一 session 的 report 与新 frames 被误当成同一次采集。 + pendingFileOperations.add( + ProfilerFileOperation( + ProfilerFileOperationKind.WRITE, + FILE_REPORT, + "", + generation, + sessionId + ) + ) + blockedFileModule = null + } + } + deliverCompletions(superseded) + dispatchNextFileOperation() + } + private fun flushPendingFrames() { - if (pendingFrames.isEmpty()) return - val batch = pendingFrames.joinToString("\n") - pendingFrames.clear() - fileModule.appendFile(FILE_FRAMES, batch) { } + val batch = synchronized(pendingFramesLock) { + if (pendingFrames.isEmpty()) return + PendingFrameBatch( + generation = sessionGeneration, + sessionId = sessionId, + content = pendingFrames.joinToString("\n") + ).also { pendingFrames.clear() } + } + enqueueFileOperation( + ProfilerFileOperation( + kind = ProfilerFileOperationKind.APPEND, + filename = FILE_FRAMES, + content = batch.content, + generation = batch.generation, + sessionId = batch.sessionId + ) + ) + } + + private fun enqueueReport( + report: RecompositionReport, + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ) { + val snapshot = synchronized(pendingFramesLock) { + SessionSnapshot(sessionGeneration, sessionId) + } + if (snapshot.sessionId.isEmpty() || report.sessionId != snapshot.sessionId) { + deliverImmediateFailure( + report.sessionId, + "report session does not match the active file-output generation", + completion + ) + return + } + enqueueFileOperation( + ProfilerFileOperation( + kind = ProfilerFileOperationKind.WRITE, + filename = FILE_REPORT, + content = report.toJson(), + generation = snapshot.generation, + sessionId = snapshot.sessionId, + completion = completion + ) + ) + } + + private fun enqueueFileOperation(operation: ProfilerFileOperation) { + var staleCompletion: CompletionDelivery? = null + var accepted = false + synchronized(fileOperationsLock) { + if (operation.generation != fileSessionGeneration) { + staleCompletion = takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + "profiler session superseded before file output was queued" + ) + ) + } else { + pendingFileOperations.add(operation) + accepted = true + } + } + staleCompletion?.let { deliverCompletion(it) } + if (accepted) dispatchNextFileOperation() + } + + private fun dispatchNextFileOperation() { + var dispatch: Pair? = null + synchronized(fileOperationsLock) { + if (inFlightFileOperation != null || pendingFileOperations.isEmpty()) return@synchronized + val module = fileModuleProvider() ?: return@synchronized + if (module === blockedFileModule) return@synchronized + val operation = pendingFileOperations.removeAt(0) + inFlightFileOperation = operation + inFlightFileModule = module + dispatch = module to operation + } + val (module, operation) = dispatch ?: return + try { + ioDispatcher.dispatch(module, operation) { result -> + completeFileOperation(module, operation, result) + } + } catch (throwable: Throwable) { + completeFileOperation( + module, + operation, + ProfilerFileIoResult.TerminalFailure( + throwable.message ?: throwable::class.simpleName.orEmpty().ifEmpty { + "file dispatcher failed" + } + ) + ) + } + } + + private fun completeFileOperation( + module: FileModule, + operation: ProfilerFileOperation, + result: ProfilerFileIoResult + ) { + val completions = mutableListOf() + var failureToLog: String? = null + var dispatchNext = false + synchronized(fileOperationsLock) { + if (inFlightFileOperation !== operation || inFlightFileModule !== module) { + return@synchronized + } + inFlightFileOperation = null + inFlightFileModule = null + if (operation.generation != fileSessionGeneration) { + blockedFileModule = null + takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + "profiler session superseded before native file output completed" + ) + )?.let(completions::add) + dispatchNext = true + } else { + when (result) { + ProfilerFileIoResult.Success -> { + blockedFileModule = null + val priorFailure = generationArtifactFailures[operation.generation] + val completionResult = + if (priorFailure == null) { + RecompositionProfilerFileOutputResult.Success(operation.sessionId) + } else { + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + "an earlier profiler file operation failed: $priorFailure" + ) + } + takeCompletionLocked(operation, completionResult)?.let(completions::add) + dispatchNext = true + } + is ProfilerFileIoResult.RetryableFailure -> { + val currentModule = fileModuleProvider() + when { + currentModule == null -> { + // There is no Pager on which to retry yet. A future registry change + // explicitly wakes the queue; the operation is not dropped. + pendingFileOperations.add(0, operation) + blockedFileModule = module + } + currentModule !== module -> { + operation.sameModuleRetryCount = 0 + pendingFileOperations.add(0, operation) + blockedFileModule = null + dispatchNext = true + } + operation.sameModuleRetryCount < MAX_SAME_MODULE_RETRIES -> { + operation.sameModuleRetryCount += 1 + pendingFileOperations.add(0, operation) + blockedFileModule = null + dispatchNext = true + } + else -> { + blockedFileModule = null + val failure = + "retryable file output exhausted after " + + "${operation.sameModuleRetryCount + 1} attempts: ${result.reason}" + recordArtifactFailureLocked(operation, failure) + takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + failure + ) + )?.let(completions::add) + failureToLog = failure + dispatchNext = true + } + } + } + is ProfilerFileIoResult.TerminalFailure -> { + blockedFileModule = null + recordArtifactFailureLocked(operation, result.reason) + takeCompletionLocked( + operation, + RecompositionProfilerFileOutputResult.Failure( + operation.sessionId, + result.reason + ) + )?.let(completions::add) + failureToLog = result.reason + dispatchNext = true + } + } + } + } + failureToLog?.let { reason -> + com.tencent.kuikly.core.log.KLog.e( + "RCProfiler", + "File output failed operation=${operation.kind} file=${operation.filename}: $reason" + ) + } + deliverCompletions(completions) + if (dispatchNext) dispatchNextFileOperation() + } + + /** Frame/header failures make the current artifact set incomplete; report-only retries do not. */ + private fun recordArtifactFailureLocked(operation: ProfilerFileOperation, reason: String) { + if (operation.filename == FILE_FRAMES && + generationArtifactFailures[operation.generation] == null + ) { + generationArtifactFailures[operation.generation] = reason + } + } + + private fun takeCompletionLocked( + operation: ProfilerFileOperation, + result: RecompositionProfilerFileOutputResult + ): CompletionDelivery? { + val callback = operation.completion ?: return null + if (operation.completionDelivered) return null + operation.completionDelivered = true + return CompletionDelivery(callback, result) + } + + private fun deliverCompletions(completions: List) { + completions.forEach(::deliverCompletion) + } + + private fun deliverCompletion(delivery: CompletionDelivery) { + try { + delivery.callback(delivery.result) + } catch (throwable: Throwable) { + com.tencent.kuikly.core.log.KLog.e( + "RCProfiler", + "File output completion callback failed: " + + (throwable.message ?: throwable::class.simpleName.orEmpty()) + ) + } + } + + private fun deliverImmediateFailure( + sessionId: String, + reason: String, + completion: ((RecompositionProfilerFileOutputResult) -> Unit)? + ) { + com.tencent.kuikly.core.log.KLog.e( + "RCProfiler", + "File output request failed session=$sessionId: $reason" + ) + completion?.let { callback -> + deliverCompletion( + CompletionDelivery( + callback, + RecompositionProfilerFileOutputResult.Failure(sessionId, reason) + ) + ) + } } private fun buildFrameJson(events: List): String { 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..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 /** * 日志输出策略。 @@ -55,12 +54,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 +71,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 +79,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 +109,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 +120,20 @@ class LogOutputStrategy( } else { "{}" } - KLog.i(TAG, " → scopes: $scopeInfo, no-scope: ${stats.noScopeRecompositions}") + logInfo(" → scopes: $scopeInfo, no-scope: ${stats.noScopeRecompositions}") } } } } + private fun logDebug(message: String) { + profilerLogDebug(TAG, message) + } + + private fun logInfo(message: String) { + profilerLogInfo(TAG, message) + } + private fun indentStr(level: Int): String = " ".repeat(level) private fun buildParamChangeString(event: ComposableRecomposedEvent): String { @@ -144,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/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensions.kt index 9a0ae9b36..3c4868779 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 @@ -198,13 +199,46 @@ private const val PULL_TO_REFRESH_ITEM_KEY = "pull_to_refresh" * Whether Compose is at top for scroll-sync correction. * Only differs from [isAtTop] when PTR [KuiklyScrollInfo.pullToRefreshTopInsetPx] > 0. */ -private fun ScrollableState.isComposeAtTopForScrollSync(): Boolean { +internal fun ScrollableState.isComposeAtTopForScrollSync(): Boolean { if (this is LazyListState && kuiklyInfo.pullToRefreshTopInsetPx > 0) { return firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 } return isAtTop() } +internal enum class InitialLazyListNativeViewportAction { + Wait, + Prepare, + Complete, +} + +/** + * Decides whether the first non-empty LazyList placement must establish the native scroll + * coordinate space before its children are placed. + * + * A non-top initial LazyList measure is already the intended Compose viewport. Deferring the + * matching native offset through [tryExpandStartSizeNoScroll] lets ScrollView expose offset-zero + * pixels first and only converge later. The initial placement is the one safe boundary where the + * native offset can still be queued for the platform's first layout, while ordinary scrolling + * keeps the existing deferred alignment policy. + */ +internal fun initialLazyListNativeViewportAction( + pending: Boolean, + hasItems: Boolean, + isComposeAtTop: Boolean, + contentOffset: Int, + composeOffset: Int, + isDragging: Boolean, + hasScrollView: Boolean, +): InitialLazyListNativeViewportAction = when { + !pending -> InitialLazyListNativeViewportAction.Complete + !hasItems || !hasScrollView -> InitialLazyListNativeViewportAction.Wait + isDragging -> InitialLazyListNativeViewportAction.Complete + isComposeAtTop -> InitialLazyListNativeViewportAction.Complete + contentOffset != 0 || composeOffset != 0 -> InitialLazyListNativeViewportAction.Complete + else -> InitialLazyListNativeViewportAction.Prepare +} + /** * Estimate Compose scroll offset when PTR item is taller than average (topInset case). */ @@ -267,9 +301,20 @@ internal fun ScrollableState.tryExpandStartSize(offset: Int, isScrolling: Boolea if (kuiklyInfo.skipExpandStartSize) return if (this is PagerState) return + val atTopSync = isComposeAtTopForScrollSync() + val needsTopExpand = offset <= 0 && !atTopSync && kuiklyInfo.offsetDirty + val needsScrollViewPullBack = offset > 0 && atTopSync + if (!needsTopExpand && !needsScrollViewPullBack) { + return + } + + if (isScrolling && kuiklyInfo.scrollView?.isDragging != true) { + return + } + 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) @@ -298,42 +343,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..0089a8527 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() } } @@ -173,7 +180,7 @@ internal fun ScrollableState.applyScrollViewOffsetDelta(delta: Int) { } else { newOffset.x.toFloat() } -} +} /** * Request scroll to top in a non-suspending way. This defers the jump to when layout is ready, 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..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 @@ -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,29 @@ 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 + + return focusedKeyInputNode?.traverseAncestorsIncludingSelf( + type = Nodes.KeyInput, + onPreVisit = { it.onPreKeyEvent(keyEvent) }, + onVisit = onFocusedItem, + onPostVisit = { it.onKeyEvent(keyEvent) } + ) ?: false + } // // @OptIn(ExperimentalComposeUiApi::class) // override fun dispatchInterceptedSoftKeyboardEvent(keyEvent: KeyEvent): Boolean { @@ -350,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( @@ -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..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 @@ -19,7 +19,6 @@ package com.tencent.kuikly.compose.ui.input.key /** * The native platform-specific keyboard key event. */ -//expect class NativeKeyEvent typealias NativeKeyEvent = Any /** @@ -31,106 +30,76 @@ typealias NativeKeyEvent = Any * * @sample androidx.compose.ui.samples.KeyEventSample */ +data class KeyEvent( + 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: NativeKeyEvent = Unit +) { + constructor(nativeKeyEvent: NativeKeyEvent) : this( + key = Key.Unknown, + nativeKeyEvent = nativeKeyEvent + ) + + 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 { + /** + * 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(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(KeyUpValue) -///** -// * 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(KeyDownValue) + } +} 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/input/pointer/HitPathTracker.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/HitPathTracker.kt index 837da2545..bf4719829 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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.collection.mutableVectorOf import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.Modifier import com.tencent.kuikly.compose.ui.layout.LayoutCoordinates +import com.tencent.kuikly.compose.ui.node.NativeDispatchPolicy import com.tencent.kuikly.compose.ui.node.Nodes import com.tencent.kuikly.compose.ui.node.dispatchForKind import com.tencent.kuikly.compose.ui.node.layoutCoordinates @@ -99,7 +100,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 +109,11 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { ) if (!changed) { root.cleanUpHover() - return false + return HitPathDispatchResult(dispatched = false, nativeDispatchCaptured = false) } + val nativeDispatchCaptured = root.capturesNativeDispatch() + // NOTE: capture resolution is branch-scoped (see resolveNativeDispatchPolicy): + // a RELEASE node only neutralizes CAPTURE ancestors on its own hit branch. var dispatchHit = root.dispatchMainEventPass( internalPointerEvent.changes, rootCoordinates, @@ -118,7 +122,10 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { ) dispatchHit = root.dispatchFinalEventPass(internalPointerEvent) || dispatchHit - return dispatchHit + return HitPathDispatchResult( + dispatched = dispatchHit, + nativeDispatchCaptured = nativeDispatchCaptured + ) } /** @@ -143,6 +150,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 @@ -150,7 +162,7 @@ internal class HitPathTracker(private val rootCoordinates: LayoutCoordinates) { */ /*@VisibleForTesting*/ @OptIn(InternalCoreApi::class, ExperimentalComposeUiApi::class) -internal open class NodeParent { +internal open class NodeParent : NativeDispatchPolicyTreeNode { val children: MutableVector = mutableVectorOf() open fun buildCache( @@ -217,6 +229,22 @@ internal open class NodeParent { return dispatched } + fun capturesNativeDispatch(): Boolean = + resolveNativeDispatchPolicyTree(this, NativeDispatchPolicy.INHERIT) == + NativeDispatchPolicy.CAPTURE + + /** + * The root has no modifiers and therefore no stance of its own; branch + * resolution semantics live in [resolveNativeDispatchPolicyTree], which + * this tree shares with the policy-tree tests. + */ + override val ownNativeDispatchStance: NativeDispatchPolicy + get() = NativeDispatchPolicy.INHERIT + + override fun forEachPolicyChild(action: (NativeDispatchPolicyTreeNode) -> Unit) { + children.forEach(action) + } + /** * Dispatches the cancel event to all child [Node]s. */ @@ -361,6 +389,24 @@ internal class Node(val modifierNode: Modifier.Node) : NodeParent() { return result } + /** + * This node's own stance from its pointer-input modifiers; branch + * resolution (top-down inherited stance, deepest-wins per path, + * any-capture-wins across siblings) is the shared + * resolveNativeDispatchPolicyTree traversal. + */ + override val ownNativeDispatchStance: NativeDispatchPolicy + get() { + if (relevantChanges.isEmpty() || !modifierNode.isAttached) { + return NativeDispatchPolicy.INHERIT + } + var own = NativeDispatchPolicy.INHERIT + modifierNode.dispatchForKind(Nodes.PointerInput) { + own = combineSameNodeNativeDispatchPolicies(own, it.resolvedNativeDispatchPolicy()) + } + return own + } + /** * 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..9b2e7f136 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchCapture.kt @@ -0,0 +1,186 @@ +/* + * 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.NativeDispatchPolicy +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) + +/** + * Marks this pointer region as a native-dispatch release boundary: native + * views in this region keep receiving MotionEvents even when a hit-path + * ancestor (for example an overlay barrier) captures native dispatch. + * + * Branch-scoped by design: the release only neutralizes capture nodes that + * are this region's own hit-path ancestors. Touches that hit a capturing + * surface without passing through this region stay captured, so overlay + * barriers keep blocking click-through everywhere else. + */ +internal fun Modifier.nativeDispatchRelease(): Modifier = this.then(NativeDispatchReleaseElement) + +/** + * Internal marker: a [PointerInputModifierNode] implementing this releases + * native dispatch for its hit branch. Kept internal so the framework's public + * ABI stays at [PointerInputModifierNode.captureNativeDispatch]. + */ +internal interface NativeDispatchReleasingNode + +/** + * Internal stance resolution for one pointer-input modifier node: the release + * marker wins, then the public capture contract, else no stance. + */ +internal fun PointerInputModifierNode.resolvedNativeDispatchPolicy(): NativeDispatchPolicy = when { + this is NativeDispatchReleasingNode -> NativeDispatchPolicy.RELEASE + captureNativeDispatch() -> NativeDispatchPolicy.CAPTURE + else -> NativeDispatchPolicy.INHERIT +} + +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 +} + +internal class NativeDispatchCaptureNode : Modifier.Node(), PointerInputModifierNode { + override fun captureNativeDispatch(): Boolean = true + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) = Unit + + override fun onCancelPointerInput() = Unit +} + +private object NativeDispatchReleaseElement : ModifierNodeElement() { + override fun create(): NativeDispatchReleaseNode = NativeDispatchReleaseNode() + + override fun update(node: NativeDispatchReleaseNode) = Unit + + override fun hashCode(): Int = NativeDispatchReleaseElement::class.hashCode() + + override fun equals(other: Any?): Boolean = other === this +} + +internal class NativeDispatchReleaseNode : + Modifier.Node(), PointerInputModifierNode, NativeDispatchReleasingNode { + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) = Unit + + override fun onCancelPointerInput() = Unit +} + +/** + * A node in the native-dispatch policy tree. HitPathTracker's real hit tree + * implements this so the SAME traversal below runs in production and in the + * policy-tree tests (no mirrored re-implementation). + */ +internal interface NativeDispatchPolicyTreeNode { + /** This node's own stance from its pointer-input modifiers. */ + val ownNativeDispatchStance: NativeDispatchPolicy + + /** Iterates this node's hit children. */ + fun forEachPolicyChild(action: (NativeDispatchPolicyTreeNode) -> Unit) +} + +/** + * The production resolution: the ancestor stance flows top-down per branch + * BEFORE sibling reduction. Each root-to-leaf path resolves to its deepest + * non-INHERIT stance (leaves return the effective path stance), then sibling + * branches combine with any-capture-wins — so a RELEASE only neutralizes + * capture ancestors on its own path, and a shared CAPTURE ancestor keeps + * capturing for every branch that does not release it itself. + */ +internal fun resolveNativeDispatchPolicyTree( + node: NativeDispatchPolicyTreeNode, + inherited: NativeDispatchPolicy, +): NativeDispatchPolicy { + val effective = combineChainNativeDispatchPolicies( + deeper = node.ownNativeDispatchStance, + own = inherited, + ) + var hasChildren = false + var combined = NativeDispatchPolicy.INHERIT + node.forEachPolicyChild { child -> + hasChildren = true + combined = combineSiblingNativeDispatchPolicies( + combined, + resolveNativeDispatchPolicyTree(child, effective) + ) + } + return if (hasChildren) combined else effective +} + +/** + * Combines two independent sibling hit branches: any capturing branch keeps + * the root capturing; a release on one branch never neutralizes a capture on + * another branch. + */ +internal fun combineSiblingNativeDispatchPolicies( + left: NativeDispatchPolicy, + right: NativeDispatchPolicy, +): NativeDispatchPolicy = when { + left == NativeDispatchPolicy.CAPTURE || right == NativeDispatchPolicy.CAPTURE -> + NativeDispatchPolicy.CAPTURE + left == NativeDispatchPolicy.RELEASE || right == NativeDispatchPolicy.RELEASE -> + NativeDispatchPolicy.RELEASE + else -> NativeDispatchPolicy.INHERIT +} + +/** + * Combines stances along one hit branch: the deeper stance wins, so a RELEASE + * overrides its capture ancestors (and a deeper CAPTURE symmetrically + * overrides a release ancestor). + */ +internal fun combineChainNativeDispatchPolicies( + deeper: NativeDispatchPolicy, + own: NativeDispatchPolicy, +): NativeDispatchPolicy = + if (deeper != NativeDispatchPolicy.INHERIT) deeper else own + +/** + * Combines stances declared by multiple pointer-input modifiers on the same + * layout node: RELEASE dominates because it is the more specific opt-out. + */ +internal fun combineSameNodeNativeDispatchPolicies( + left: NativeDispatchPolicy, + right: NativeDispatchPolicy, +): NativeDispatchPolicy = when { + left == NativeDispatchPolicy.RELEASE || right == NativeDispatchPolicy.RELEASE -> + NativeDispatchPolicy.RELEASE + left == NativeDispatchPolicy.CAPTURE || right == NativeDispatchPolicy.CAPTURE -> + NativeDispatchPolicy.CAPTURE + else -> NativeDispatchPolicy.INHERIT +} 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/layout/SubcomposeLayout.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/layout/SubcomposeLayout.kt index 02b94e2fe..41540ddc9 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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.ComposeNodeLifecycleCallback import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReusableComposeNode import androidx.compose.runtime.ReusableComposition @@ -71,6 +72,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 @@ -230,6 +232,7 @@ fun SubcomposeLayout( val materialized = currentComposer.materialize(modifier) scrollableState.kuiklyInfo.orientation = orientation scrollableState.kuiklyInfo.pageData = LocalConfiguration.current.pageData + val isAndroid = LocalConfiguration.current.isAndroid val isPagerView = scrollableState is PagerState || scrollableState is DrawerInternalPagerState val isDrawerPager = scrollableState is DrawerInternalPagerState val coroutineScope = rememberCoroutineScope() @@ -263,7 +266,7 @@ fun SubcomposeLayout( } if (scrollableState is PagerState || scrollableState is DrawerInternalPagerState) { - willDragEndBySync(isSync = scrollableState is PagerState, handler = { + willDragEndBySync(isSync = scrollableState is PagerState && !isAndroid, handler = { val viewportSize = kuiklyInfo.viewportSize val scaleParams = it.scaleWithDensity(kuiklyInfo.getDensity()) // 实现分页滑动 @@ -327,21 +330,30 @@ fun SubcomposeLayout( return@scroll } - val prevOffset = kuiklyInfo.contentOffset kuiklyInfo.contentOffset = offset (scrollableState as? PagerState)?.onNativeContentOffsetChanged(offset) (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 + when ( + kuiklyInfo.resolveNativeScrollEvent( + offsetX = scaleParams.offsetX, + offsetY = scaleParams.offsetY, + epsilon = 0.5 * kuiklyInfo.getDensity(), + ) + ) { + 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 } - return@scroll + KuiklyScrollInfo.NativeScrollEventDisposition.Dispatch -> Unit } // 忽略较小的滑动 @@ -394,6 +406,15 @@ fun SubcomposeLayout( scrollViewRef?.listenScrollEvent() } + DisposableEffect(scrollViewRef, scrollableState) { + val boundScrollView = scrollViewRef + onDispose { + if (scrollableState.kuiklyInfo.scrollView === boundScrollView) { + scrollableState.kuiklyInfo.scrollView = null + } + } + } + ReusableComposeNode, KuiklyApplier>( factory = { val newView = ScrollerView() @@ -436,6 +457,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) @@ -777,6 +800,7 @@ internal class LayoutNodeSubcompositionsState( @Suppress("ExceptionMessage") checkPrecondition(precomposedCount > 0) precomposedCount-- + precomposed.invalidateDrawAfterSubcomposeSlotActivation() precomposed } else { takeNodeFromReusables(slotId) @@ -1063,6 +1087,7 @@ internal class LayoutNodeSubcompositionsState( nodeState.activeState = mutableStateOf(true) nodeState.forceReuse = true nodeState.forceRecompose = true + node.invalidateDrawAfterSubcomposeSlotActivation() node } } @@ -1367,6 +1392,22 @@ internal class LayoutNodeSubcompositionsState( } } +/** + * Wakes the draw path when a retained or precomposed lazy slot becomes active again. + * + * [disposeOrReuseStartingFromIndex] hides a retained slot's native descendants after placement. + * The next measure can take the same slot back from the reusable section without moving it or + * changing its modifier chain. In that case placement restores the descendants' visibility props, + * but a clean virtual slot container can still prevent an already-dirty descendant from reaching + * the render root. The same stranded-dirty state can occur when a precomposed slot is drawn past + * while unplaced and is only made active by a later measure. This activation-specific invalidation + * deliberately crosses consecutive dirty ancestors; ordinary [LayoutNode.invalidateDraw] + * coalescing cannot repair either boundary. + */ +internal fun LayoutNode.invalidateDrawAfterSubcomposeSlotActivation() { + (this as? KNode<*>)?.invalidateDrawAndForceAncestors() +} + private val ReusedSlotId = object { override fun toString(): String = "ReusedSlotId" diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNode.kt index de814536b..6286cab3e 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNode.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNode.kt @@ -32,10 +32,12 @@ import com.tencent.kuikly.core.base.DeclarativeBaseView * @sample com.tencent.kuikly.compose.ui.samples.DrawModifierNodeSample */ interface DrawModifierNode : DelegatableNode { - fun ContentDrawScope.draw() { } + fun ContentDrawScope.draw() { + drawContent() + } fun onMeasureResultChanged() {} fun ContentDrawScope.draw(view: DeclarativeBaseView<*, *>?) { - drawContent() + draw() } } 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..7c38060f6 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 @@ -28,6 +28,7 @@ import com.tencent.kuikly.compose.ui.graphics.Matrix import com.tencent.kuikly.compose.ui.graphics.isIdentity import com.tencent.kuikly.compose.ui.layout.LayoutCoordinates import com.tencent.kuikly.compose.ui.platform.LocalDensity +import com.tencent.kuikly.compose.ui.unit.IntOffset import com.tencent.kuikly.compose.ui.unit.IntSize import com.tencent.kuikly.compose.views.VirtualNodeView import com.tencent.kuikly.compose.layout.resetViewVisible @@ -56,6 +57,26 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +import com.tencent.kuikly.compose.diagnostics.LazyLayoutTraceConfig +import com.tencent.kuikly.core.log.KLog + +internal fun correctedComposeOffsetForViewportChange( + composeOffset: Int, + nativeOffset: Int, + contentSize: Int, + previousViewportSize: Int, + newViewportSize: Int, + programmaticOffsetPending: Boolean, +): Int? { + if (programmaticOffsetPending || previousViewportSize == newViewportSize) { + return null + } + + val clampedNativeOffset = nativeOffset.coerceAtLeast(0) + val newMaxOffset = maxOf(0, contentSize - newViewportSize.coerceAtLeast(0)) + val correctedOffset = minOf(clampedNativeOffset, newMaxOffset) + return correctedOffset.takeIf { it != composeOffset } +} internal class KNode>( val view: T, @@ -143,6 +164,21 @@ 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. + // + // 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. + invalidateDrawAndForceAncestors() + } + 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. @@ -232,6 +268,22 @@ internal class KNode>( } } + internal fun invalidateDrawAndForceAncestors() { + drawInvalidated = true + val currentParent = parent + if (currentParent is KNode<*>) { + currentParent.invalidateDrawAndForceAncestors() + } else { + currentParent?.invalidateDraw() + } + } + + internal fun clearDrawInvalidationForTest() { + drawInvalidated = false + } + + internal fun isDrawInvalidatedForTest(): Boolean = drawInvalidated + override fun onWillStartMeasure() { super.onWillStartMeasure() kuiklyCoordinates = null @@ -302,18 +354,21 @@ internal class KNode>( } /** - * Corrects composeOffset when ScrollView height changes + * Reconciles composeOffset when the ScrollView viewport changes. * Mainly handles the following scenarios: * 1. When currently scrolled to the bottom and height becomes shorter, adjust offset to avoid exceeding boundaries * 2. When there is current offset but height increases, scrolling may no longer be needed, set offset to 0 + * 3. When a programmatic owner is pending during a shrink, preserve its Compose target and return it + * so updateFrame can replay the target only after the smaller native frame has been committed */ - private fun updateScrollViewOffset(curFrame: Frame, newFrame: Frame) { + private fun updateScrollViewOffset(curFrame: Frame, newFrame: Frame): IntOffset? { if (view !is ScrollerView<*, *>) { - return + return null } val scrollerView = view as ScrollerView<*, *> - val kuiklyInfo = (scrollerView.renderProperties as? RenderProperties)?.kuiklyScrollInfo ?: return + val kuiklyInfo = + (scrollerView.renderProperties as? RenderProperties)?.kuiklyScrollInfo ?: return null if (curFrame != newFrame) { kuiklyInfo.offsetDirty = true @@ -325,7 +380,7 @@ internal class KNode>( // If height hasn't changed, no correction needed if (curHeight == newHeight) { - return + return null } // Get current scroll offset - convert to pixel units @@ -337,50 +392,93 @@ internal class KNode>( // Calculate maximum scrollable distance - use pixel units, consistent with composeOffset val currentContentSize = kuiklyInfo.currentContentSize // already in pixel units + val previousViewportSize = if (kuiklyInfo.isVertical()) { + (curHeight * kuiklyInfo.getDensity()).toInt() + } else { + (curFrame.width * kuiklyInfo.getDensity()).toInt() + } val viewportSize = if (kuiklyInfo.isVertical()) { (newHeight * kuiklyInfo.getDensity()).toInt() } else { (newFrame.width * kuiklyInfo.getDensity()).toInt() } - // Handle edge cases: if contentSize is 0 or viewportSize is 0, no scrolling needed - if (currentContentSize <= 0) { - kuiklyInfo.composeOffset = 0f - return + val pendingProgrammaticOffset = kuiklyInfo.ignoreScrollOffset + // task #990 diagnostic: a shrink corrects the Compose offset here, while + // the native scroller is only re-driven when a pending programmatic + // offset exists. Whether one exists at this moment is not decidable by + // reading — it is written on two paths that each return early, and it + // can also be consumed by a native echo beforehand. Record it. + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=KNode.updateScrollViewOffset " + + "shrink=${viewportSize < previousViewportSize} " + + "viewportSize=$previousViewportSize->$viewportSize " + + "composeOffset=${kuiklyInfo.composeOffset.toInt()} " + + "nativeOffset=$currentOffset contentSize=$currentContentSize " + + "pendingProgrammaticOffset=$pendingProgrammaticOffset " + + "offsetDirty=${kuiklyInfo.offsetDirty}" + ) } - val maxScrollOffset = maxOf(0, currentContentSize - viewportSize) - - // Correct composeOffset - use pixel units - val correctedOffset = when { - // If currently scrolled to bottom and height becomes shorter, adjust offset - currentOffset >= maxScrollOffset && newHeight < curHeight -> { - val newMaxScrollOffset = maxOf(0, currentContentSize - viewportSize) - minOf(currentOffset, newMaxScrollOffset) - } - // If there is current offset but height increases, check if adjustment is needed - currentOffset > 0 && newHeight > curHeight -> { - val newMaxScrollOffset = maxOf(0, currentContentSize - viewportSize) - if (newMaxScrollOffset <= 0) { - 0 // no scrolling needed - } else { - // When height increases, keep composeOffset unchanged, but check if it exceeds boundaries - if (currentOffset > newMaxScrollOffset) { - newMaxScrollOffset // adjust to maximum when exceeding boundaries - } else { - currentOffset // keep unchanged when within boundaries - } - } + correctedComposeOffsetForViewportChange( + composeOffset = kuiklyInfo.composeOffset.toInt(), + nativeOffset = currentOffset, + contentSize = currentContentSize, + previousViewportSize = previousViewportSize, + newViewportSize = viewportSize, + programmaticOffsetPending = pendingProgrammaticOffset != null, + )?.let { correctedOffset -> + kuiklyInfo.composeOffset = correctedOffset.toFloat() + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=KNode.updateScrollViewOffset.corrected " + + "composeOffsetCorrectedTo=$correctedOffset nativeOffsetLeftAt=$currentOffset" + ) } - // Other cases keep current offset - else -> { - currentOffset + } + + return pendingProgrammaticOffset?.takeIf { viewportSize < previousViewportSize } + } + + private fun replayPendingScrollOffsetAfterViewportShrink(pendingOffset: IntOffset?) { + val offset = pendingOffset ?: return + val scrollerView = view as? ScrollerView<*, *> ?: return + val kuiklyInfo = + (scrollerView.renderProperties as? RenderProperties)?.kuiklyScrollInfo ?: return + + // setFrameToRenderView can synchronously deliver the old native echo. Never resurrect a + // consumed target, or overwrite a newer programmatic owner captured during the resize. + // task #990 diagnostic: #117's replay only fires when the captured owner + // is still current. Record both outcomes so a silent skip is visible. + if (kuiklyInfo.ignoreScrollOffset != offset) { + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=KNode.replayPendingScrollOffsetAfterViewportShrink " + + "skipped=owner_no_longer_current captured=$offset " + + "current=${kuiklyInfo.ignoreScrollOffset}" + ) } + return + } + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=KNode.replayPendingScrollOffsetAfterViewportShrink replaying=$offset" + ) } - // Update composeOffset - if (correctedOffset != currentOffset) { - kuiklyInfo.composeOffset = correctedOffset.toFloat() + val density = kuiklyInfo.getDensity() + val offsetX = offset.x / density + val offsetY = offset.y / density + if (scrollerView.contentView?.getPager()?.pageData?.isAndroid == true) { + // Keep the existing Android exact-bottom workaround used by applyOffsetDelta. + scrollerView.setContentOffset(max(0f, offsetX - 0.01f), max(0f, offsetY - 0.01f)) + } else { + scrollerView.setContentOffset(offsetX, offsetY) } } @@ -394,8 +492,9 @@ internal class KNode>( height = newFrame.height ) - updateScrollViewOffset(curFrame, densityFrame) + val pendingOffset = updateScrollViewOffset(curFrame, densityFrame) setFrameToRenderView(densityFrame) + replayPendingScrollOffsetAfterViewportShrink(pendingOffset) getViewEvent().notifyLayoutFrameDidChange(newFrame) } } @@ -715,4 +814,4 @@ internal class KNode>( ) } } -} \ No newline at end of file +} 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) } 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/node/PointerInputModifierNode.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/node/PointerInputModifierNode.kt index 62bed7383..9820d3863 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. @@ -119,6 +129,26 @@ interface PointerInputModifierNode : DelegatableNode { } } +/** + * Stance of a [PointerInputModifierNode] on whether the host render root should + * capture (withhold) native child dispatch for the current gesture. Internal: + * the public contract stays [PointerInputModifierNode.captureNativeDispatch]; + * RELEASE is only expressible through the internal release marker node. + */ +internal enum class NativeDispatchPolicy { + /** No stance; defer to other nodes in the hit branch. */ + INHERIT, + + /** Withhold MotionEvents from native children under the compose root. */ + CAPTURE, + + /** + * Let native children receive MotionEvents even when a hit-path ancestor + * captures. Only overrides ancestors within the same hit branch. + */ + RELEASE, +} + internal val PointerInputModifierNode.isAttached: Boolean get() = node.isAttached 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..fa36771ef 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) { @@ -292,7 +297,7 @@ internal class RootNodeOwner( measureAndLayoutDelegate.onNodeDetached(node) snapshotObserver.clear(node) needClearObservations = true - semanticsKuiklyHandler.clearCache() + semanticsKuiklyHandler.onNodeDetached(node.semanticsId) } override fun measureAndLayout(sendPointerUpdate: Boolean) { @@ -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) { 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..86a8a2a14 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducer.kt @@ -0,0 +1,271 @@ +/* + * 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 completionAuthorityView: T? = null + private var completionAuthorityGeneration: 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 + val commands = cancelSupersededPendingFocus(view) + revokeCompletionAuthority() + return commands + } + + internal fun stop(view: T): List> { + if (desiredView !== view) return emptyList() + generation += 1 + desiredView = null + focusAttemptCount = 0 + val commands = cancelSupersededPendingFocus(null) + revokeCompletionAuthority() + return commands + } + + internal fun reconcile(reassertCurrentFocus: Boolean = false): Command? { + val target = desiredView + if (target == null) { + val active = observedView ?: return null + if (pendingBlurView === active) return null + onBlurRequested(active) + pendingBlurView = active + return Command.Blur(active, generation) + } + if (observedView === target) { + return if (reassertCurrentFocus) { + // Focus is already owned by this editor, but the platform keyboard may have been + // dismissed independently (for example Android Back hides IME without clearing + // EditText focus). Reissuing the generation-scoped native focus command lets the + // renderer show the keyboard without changing logical focus ownership. + completionAuthorityView = target + completionAuthorityGeneration = generation + Command.Focus(target, generation) + } else { + null + } + } + if (pendingFocusView === target && pendingFocusGeneration == generation) return null + if (focusAttemptCount >= MaxFocusAttemptsPerGeneration) return null + pendingFocusView = target + pendingFocusGeneration = generation + completionAuthorityView = target + completionAuthorityGeneration = generation + focusAttemptCount += 1 + pendingBlurView = null + return Command.Focus(target, generation) + } + + internal fun onNativeFocus(view: T, requestId: Long?): NativeFocusDecision { + if (requestId != null) { + // A request id identifies the logical focus generation, not one transport attempt. + // The pending slot may already have been consumed by an earlier user/native focus + // observation for this same target, or released by a retry timeout, before the + // programmatic completion crosses the bridge. Completion authority therefore lives + // independently from the retry slot, but is revoked by blur intent so a late callback + // cannot revive an editor after the keyboard or user dismissed it. + val matchesCurrentGeneration = + requestId == generation && + desiredView === view && + completionAuthorityView === view && + completionAuthorityGeneration == requestId + if (!matchesCurrentGeneration) { + return NativeFocusDecision.IgnoreStale + } + observedView = view + pendingBlurView = null + 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. + observedView = view + pendingBlurView = null + if (desiredView === view) { + pendingFocusView = null + pendingFocusGeneration = null + focusAttemptCount = 0 + return NativeFocusDecision.Confirmed + } + 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 + if (pendingBlurView === view) pendingBlurView = null + onBlurRequested(view) + 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 + } + revokeCompletionAuthority(view) + 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 + } + + internal fun rejectNativeFocus(view: T) { + onBlurRequested(view) + if (observedView === view) observedView = null + } + + /** + * Revokes permission for an in-flight native completion before an explicit blur is sent. + * + * Blur can preserve [desiredView] (for example, SoftwareKeyboardController.hide()), so the + * logical generation alone cannot distinguish an obsolete completion from one that is still + * allowed to establish native focus. A later [reconcile] call may emit a fresh Focus command + * in the same generation, which reopens authority for hide -> show recovery. + */ + internal fun onBlurRequested(view: T) { + revokeCompletionAuthority(view) + if (pendingFocusView === view) { + pendingFocusView = null + pendingFocusGeneration = null + } + if (desiredView === view) focusAttemptCount = 0 + } + + /** + * 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 fun revokeCompletionAuthority(view: T? = null) { + if (view != null && completionAuthorityView !== view) return + completionAuthorityView = null + completionAuthorityGeneration = null + } + + 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..5f6dddbd2 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,129 @@ 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 + private var keyboardShowRequested = false override fun show() { - activeView?.also { sendInputCommand(it, PendingAction.SHOW_KEYBOARD) } + keyboardHidden = false + keyboardShowRequested = true + scheduleReconcile(focusReducer.desiredView ?: focusReducer.observedView) } override fun hide() { - activeView?.also { sendInputCommand(it, PendingAction.HIDE_KEYBOARD) } + keyboardHidden = true + keyboardShowRequested = false + val target = focusReducer.desiredView ?: focusReducer.observedView + target?.let(focusReducer::onBlurRequested) + scheduleReconcile(target) } 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) { + // 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 + } + + internal fun onNativeFocusIntent( + view: AutoHeightTextAreaView, + ): InputFocusTargetReducer.NativeFocusDecision = + focusReducer.onNativeFocusIntent(view) + + internal fun onNativeBlur( + view: AutoHeightTextAreaView, + requestId: Long?, + ): InputFocusTargetReducer.NativeBlurDecision { + val decision = focusReducer.onNativeBlur(view, requestId) + scheduleReconcile(view) + return decision + } + + internal fun rejectNativeFocus(view: AutoHeightTextAreaView) { + focusReducer.rejectNativeFocus(view) + view.blur(focusReducer.generation) } - 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 -> {} + 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 + val reassertCurrentFocus = keyboardShowRequested + keyboardShowRequested = false + if (!keyboardHidden || focusReducer.desiredView == null) { + execute( + focusReducer.reconcile( + reassertCurrentFocus = + reassertCurrentFocus && focusReducer.desiredView != null, + ), + ) + } + if (keyboardHidden) { + focusReducer.observedView?.let { observedView -> + focusReducer.onBlurRequested(observedView) + observedView.blur(focusReducer.generation) } - pendingAction = PendingAction.NONE - pendingView = null } } - 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 -> { + focusReducer.onBlurRequested(command.view) + 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/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/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/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/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..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 @@ -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. @@ -98,8 +101,12 @@ 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, + val textDecorationThickness: TextUnit = TextUnit.Unspecified, + val textDecorationOffset: TextUnit = TextUnit.Unspecified, // val platformStyle: PlatformSpanStyle? = null, // val drawStyle: DrawStyle? = null ) { @@ -131,6 +138,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. @@ -152,8 +162,12 @@ 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, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle? = null, // drawStyle: DrawStyle? = null ) : this( @@ -169,8 +183,12 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -206,6 +224,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. @@ -228,8 +249,12 @@ 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, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle? = null, // drawStyle: DrawStyle? = null ) : this( @@ -245,8 +270,12 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -294,8 +323,12 @@ 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, + textDecorationThickness = other.textDecorationThickness, + textDecorationOffset = other.textDecorationOffset, // platformStyle = other.platformStyle, // drawStyle = other.drawStyle ) @@ -320,8 +353,12 @@ 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, + textDecorationThickness: TextUnit = this.textDecorationThickness, + textDecorationOffset: TextUnit = this.textDecorationOffset, // platformStyle: PlatformSpanStyle? = this.platformStyle, // drawStyle: DrawStyle? = this.drawStyle ): SpanStyle { @@ -342,8 +379,12 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -363,8 +404,12 @@ 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, + textDecorationThickness: TextUnit = this.textDecorationThickness, + textDecorationOffset: TextUnit = this.textDecorationOffset, // platformStyle: PlatformSpanStyle? = this.platformStyle, // drawStyle: DrawStyle? = this.drawStyle ): SpanStyle { @@ -381,8 +426,12 @@ class SpanStyle internal constructor( // textGeometricTransform = textGeometricTransform, // localeList = localeList, background = background, + inlineBoxStyle = inlineBoxStyle, textDecoration = textDecoration, shadow = shadow, + textDecorationColor = textDecorationColor, + textDecorationThickness = textDecorationThickness, + textDecorationOffset = textDecorationOffset, // platformStyle = platformStyle, // drawStyle = drawStyle ) @@ -408,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 } @@ -415,6 +465,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 @@ -435,7 +488,11 @@ 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() + result = 31 * result + textDecorationOffset.hashCode() result = 31 * result + (shadow?.hashCode() ?: 0) // result = 31 * result + (platformStyle?.hashCode() ?: 0) // result = 31 * result + (drawStyle?.hashCode() ?: 0) @@ -454,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 } @@ -477,7 +535,11 @@ 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, ") + append("textDecorationOffset=$textDecorationOffset, ") append("shadow=$shadow, ") // append("platformStyle=$platformStyle, ") // append("drawStyle=$drawStyle") @@ -564,11 +626,27 @@ 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, 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(), @@ -613,7 +691,11 @@ 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, + textDecorationOffset = style.textDecorationOffset, shadow = style.shadow ?: Shadow.None, // platformStyle = style.platformStyle, // drawStyle = style.drawStyle ?: Fill @@ -634,8 +716,12 @@ internal fun SpanStyle.fastMerge( // textGeometricTransform: TextGeometricTransform?, // localeList: LocaleList?, background: Color, + inlineBoxStyle: InlineBoxSpanStyle?, textDecoration: TextDecoration?, shadow: Shadow?, + textDecorationColor: Color = Color.Unspecified, + textDecorationThickness: TextUnit = TextUnit.Unspecified, + textDecorationOffset: TextUnit = TextUnit.Unspecified, // platformStyle: PlatformSpanStyle?, // drawStyle: DrawStyle? ): SpanStyle { @@ -661,6 +747,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 || @@ -670,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 || @@ -703,7 +793,19 @@ 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) { + 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/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/commonMain/kotlin/com/tencent/kuikly/compose/ui/window/Dialog.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/window/Dialog.kt index 4402c3a14..e229702c5 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/window/Dialog.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/window/Dialog.kt @@ -30,6 +30,7 @@ import com.tencent.kuikly.compose.BackHandler import com.tencent.kuikly.compose.KuiklyApplier import com.tencent.kuikly.compose.foundation.clickable import com.tencent.kuikly.compose.foundation.layout.Box +import com.tencent.kuikly.compose.extension.updatedNodeEvent import com.tencent.kuikly.compose.ui.ExperimentalComposeUiApi import com.tencent.kuikly.compose.ui.InternalComposeUiApi import com.tencent.kuikly.compose.ui.Modifier @@ -68,6 +69,7 @@ import com.tencent.kuikly.core.base.Attr.StyleConst import com.tencent.kuikly.core.base.event.Touch import com.tencent.kuikly.core.views.DivView import com.tencent.kuikly.core.views.ModalView +import com.tencent.kuikly.core.views.ModalDismissReason import com.tencent.kuikly.core.views.willDismiss import kotlin.js.JsName import kotlin.math.min @@ -306,6 +308,9 @@ private fun DialogLayout( // 插槽标识符 var slotId = remember { 0 } val backPressedDispatcher= LocalOnBackPressedDispatcherOwner.current + val willDismissEvent: (ModalDismissReason) -> Unit = updatedNodeEvent { _: ModalDismissReason -> + backPressedDispatcher.onBackPressedDispatcher.dispatchOnBackEvent() + } DisposableEffect(Unit) { @@ -316,9 +321,7 @@ private fun DialogLayout( KNode(ModalView().also { it.inWindow = currentProperties.inWindow }) { - getViewEvent().willDismiss { - backPressedDispatcher.onBackPressedDispatcher.dispatchOnBackEvent() - } + getViewEvent().willDismiss(willDismissEvent) } }, update = { diff --git a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/views/ScrollViewEx.kt b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/views/ScrollViewEx.kt index 55638f1eb..13054ef28 100644 --- a/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/views/ScrollViewEx.kt +++ b/compose/src/commonMain/kotlin/com/tencent/kuikly/compose/views/ScrollViewEx.kt @@ -26,6 +26,8 @@ import com.tencent.kuikly.core.views.ScrollerAttr.Companion.NESTED_SCROLL import com.tencent.kuikly.core.views.ScrollerEvent import com.tencent.kuikly.core.views.ScrollerView import kotlin.math.max +import com.tencent.kuikly.compose.diagnostics.LazyLayoutTraceConfig +import com.tencent.kuikly.core.log.KLog internal val KuiklyInfoKey = "KuiklyInfoKey" @@ -53,6 +55,15 @@ internal fun ScrollerView.applyOffsetDelta(delta: I return newOffset } + // task #990 diagnostic: one of only two install points for the pending + // programmatic offset, and the early return above means an unchanged offset + // installs nothing at all. + if (LazyLayoutTraceConfig.ENABLED) { + KLog.i( + "KuiklyViewportShrink", + "producer=ScrollViewEx.applyOffsetDelta.install installed=$newOffset" + ) + } kuiklyInfo.ignoreScrollOffset = newOffset // 扩容 diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmissionTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmissionTest.kt new file mode 100644 index 000000000..a9211ae5c --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyIdleAdmissionTest.kt @@ -0,0 +1,58 @@ +/* + * 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. + */ +package com.tencent.kuikly.compose.coroutines.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KuiklyIdleAdmissionTest { + + @Test + fun normalWorkAlwaysWinsOverIdleAdmission() { + assertEquals( + KuiklyIdleAdmissionDecision.WaitForNormalDrain, + kuiklyIdleAdmissionDecision( + hasIdleWork = true, + hasNormalWork = true, + scheduledGeneration = 4, + currentGeneration = 4 + ) + ) + } + + @Test + fun newNormalGenerationReschedulesStaleIdleMarker() { + assertEquals( + KuiklyIdleAdmissionDecision.Reschedule, + kuiklyIdleAdmissionDecision( + hasIdleWork = true, + hasNormalWork = false, + scheduledGeneration = 4, + currentGeneration = 5 + ) + ) + } + + @Test + fun stableDrainedGenerationRunsOneIdleCallback() { + assertEquals( + KuiklyIdleAdmissionDecision.Run, + kuiklyIdleAdmissionDecision( + hasIdleWork = true, + hasNormalWork = false, + scheduledGeneration = 5, + currentGeneration = 5 + ) + ) + assertTrue(shouldScheduleKuiklyIdle(true, false, false)) + assertFalse(shouldScheduleKuiklyIdle(true, true, false)) + assertFalse(shouldScheduleKuiklyIdle(true, false, true)) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NativeSemanticsNodeRegistryTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NativeSemanticsNodeRegistryTest.kt new file mode 100644 index 000000000..0d66b5cd2 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NativeSemanticsNodeRegistryTest.kt @@ -0,0 +1,181 @@ +/* + * 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.extension + +import com.tencent.kuikly.compose.ui.semantics.Role +import com.tencent.kuikly.core.base.attr.AccessibilityRole +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class NativeSemanticsNodeRegistryTest { + private data class ProjectionNode( + val name: String, + val hidden: Boolean = false, + val parent: ProjectionNode? = null + ) + + private data class LayoutProjectionNode( + val hidden: Boolean = false, + val parent: LayoutProjectionNode? = null + ) + + private data class SemanticProjectionNode( + val name: String, + val layoutNode: LayoutProjectionNode, + val semanticParent: SemanticProjectionNode? = null + ) + + @Test + fun invisibleNodeExcludesItsNativeSubtree() { + assertEquals( + AccessibilityRole.HIDDEN, + resolveNativeAccessibilityRole(isInvisibleToUser = true, hasAccessibilityText = false, role = null) + ) + } + + @Test + fun visibleEmptyContainerRestoresDescendantTraversal() { + assertEquals( + AccessibilityRole.NONE, + resolveNativeAccessibilityRole(isInvisibleToUser = false, hasAccessibilityText = false, role = null) + ) + } + + @Test + fun visibleButtonKeepsItsNativeRole() { + assertEquals( + AccessibilityRole.BUTTON, + resolveNativeAccessibilityRole(isInvisibleToUser = false, hasAccessibilityText = true, role = Role.Button) + ) + } + + @Test + fun hiddenAncestorProjectsToEveryFlattenedNativeDescendant() { + val hiddenRoot = ProjectionNode(name = "content", hidden = true) + val navigation = ProjectionNode(name = "navigation", parent = hiddenRoot) + val search = ProjectionNode(name = "search", parent = navigation) + val drawer = ProjectionNode(name = "drawer") + + val hiddenNodes = effectivelyHiddenNodes( + nodes = listOf(hiddenRoot, navigation, search, drawer), + isHidden = ProjectionNode::hidden, + parentOf = ProjectionNode::parent + ) + + assertEquals(setOf(hiddenRoot, navigation, search), hiddenNodes) + } + + @Test + fun clearingHiddenAncestorRestoresTheProjectedSubtree() { + val visibleRoot = ProjectionNode(name = "content") + val search = ProjectionNode(name = "search", parent = visibleRoot) + + val hiddenNodes = effectivelyHiddenNodes( + nodes = listOf(visibleRoot, search), + isHidden = ProjectionNode::hidden, + parentOf = ProjectionNode::parent + ) + + assertTrue(hiddenNodes.isEmpty()) + } + + @Test + fun layoutAncestryProjectsHiddenAcrossDisconnectedSemanticBranches() { + val hiddenLayoutRoot = LayoutProjectionNode(hidden = true) + val contentLayout = LayoutProjectionNode(parent = hiddenLayoutRoot) + val searchLayout = LayoutProjectionNode(parent = contentLayout) + val drawerLayout = LayoutProjectionNode() + val hiddenRoot = SemanticProjectionNode(name = "content", layoutNode = hiddenLayoutRoot) + val search = SemanticProjectionNode( + name = "search", + layoutNode = searchLayout, + semanticParent = null + ) + val drawer = SemanticProjectionNode(name = "drawer", layoutNode = drawerLayout) + + val hiddenNodes = effectivelyHiddenNodes( + nodes = listOf(hiddenRoot, search, drawer), + firstAncestor = SemanticProjectionNode::layoutNode, + isHidden = LayoutProjectionNode::hidden, + parentOf = LayoutProjectionNode::parent + ) + + assertEquals(setOf(hiddenRoot, search), hiddenNodes) + } + + @Test + fun reconcileReturnsOnlyNodesRemovedFromCurrentGeneration() { + val registry = NativeSemanticsNodeRegistry() + val retained = Any() + val removed = Any() + + assertTrue(registry.reconcile(mapOf(1 to retained, 2 to removed)).isEmpty()) + + val removedNodes = registry.reconcile(mapOf(1 to retained)) + + assertEquals(1, removedNodes.size) + assertSame(removed, removedNodes.single()) + } + + @Test + fun reconcileTreatsReusedIdWithNewNodeAsReplacement() { + val registry = NativeSemanticsNodeRegistry() + val previous = Any() + val replacement = Any() + registry.reconcile(mapOf(1 to previous)) + + val removedNodes = registry.reconcile(mapOf(1 to replacement)) + + assertEquals(1, removedNodes.size) + assertSame(previous, removedNodes.single()) + assertTrue(registry.reconcile(mapOf(1 to replacement)).isEmpty()) + } + + @Test + fun removedIdCanBeAddedAgainAsAFirstGenerationNode() { + val registry = NativeSemanticsNodeRegistry() + val node = Any() + registry.reconcile(mapOf(1 to node)) + assertEquals(listOf(node), registry.reconcile(emptyMap())) + + assertTrue(registry.reconcile(mapOf(1 to node)).isEmpty()) + } + + @Test + fun clearReturnsRetainedNodesAndEmptiesRegistry() { + val registry = NativeSemanticsNodeRegistry() + val first = Any() + val second = Any() + registry.reconcile(mapOf(1 to first, 2 to second)) + + assertEquals(listOf(first, second), registry.clear()) + assertTrue(registry.clear().isEmpty()) + assertTrue(registry.reconcile(mapOf(1 to first)).isEmpty()) + } + + @Test + fun removeForgetsOnlyDetachedNode() { + val registry = NativeSemanticsNodeRegistry() + val detached = Any() + val retained = Any() + registry.reconcile(mapOf(1 to detached, 2 to retained)) + + assertSame(detached, registry.remove(1)) + assertEquals(listOf(retained), registry.clear()) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NodeEventBinderTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NodeEventBinderTest.kt new file mode 100644 index 000000000..b08eb35b5 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/extension/NodeEventBinderTest.kt @@ -0,0 +1,61 @@ +/* + * 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.extension + +import androidx.compose.runtime.snapshots.Snapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class NodeEventBinderTest { + @Test + fun retainedHandlerTracksInactiveActiveInactiveLifecycle() { + val dispatches = mutableListOf() + val binder = NodeEventBinder { dispatches += "inactive:$it" } + val retainedHandler = binder.event + + retainedHandler("prewarm") + binder.update { dispatches += "active:$it" } + retainedHandler("visible") + binder.update { dispatches += "inactive:$it" } + retainedHandler("kept-alive") + + assertEquals( + listOf("inactive:prewarm", "active:visible", "inactive:kept-alive"), + dispatches, + ) + assertSame(retainedHandler, binder.event) + } + + @Test + fun abortedSnapshotDoesNotPublishUncommittedDelegate() { + val dispatches = mutableListOf() + val binder = NodeEventBinder { dispatches += "committed:$it" } + val abortedComposition = Snapshot.takeMutableSnapshot() + + try { + abortedComposition.enter { + binder.update { dispatches += "aborted:$it" } + } + } finally { + abortedComposition.dispose() + } + + binder.event("native-event") + + assertEquals(listOf("committed:native-event"), dispatches) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawerInteractionPolicyTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawerInteractionPolicyTest.kt new file mode 100644 index 000000000..6be7aed1a --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/drawer/MoveableDrawerInteractionPolicyTest.kt @@ -0,0 +1,21 @@ +package com.tencent.kuikly.compose.foundation.drawer + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MoveableDrawerInteractionPolicyTest { + @Test + fun defaultPolicyPreservesExistingPagerInteraction() { + val policy = moveableDrawerInteractionPolicy(userScrollEnabled = true) + + assertTrue(policy.pagerUserScrollEnabled) + } + + @Test + fun disabledPolicyRemovesOnlyUserGestureAndScrollSemantics() { + val policy = moveableDrawerInteractionPolicy(userScrollEnabled = false) + + assertFalse(policy.pagerUserScrollEnabled) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListInitialNativeViewportTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListInitialNativeViewportTest.kt new file mode 100644 index 000000000..4e84ea8ea --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/lazy/LazyListInitialNativeViewportTest.kt @@ -0,0 +1,27 @@ +/* + * 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. + */ + +package com.tencent.kuikly.compose.foundation.lazy + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LazyListInitialNativeViewportTest { + + @Test + fun nativeViewportIsPreparedBeforeAnyChildFrameIsPlaced() { + val commits = mutableListOf() + + placeLazyListChildrenWithInitialNativeViewport( + placementScope = Unit, + prepareInitialNativeViewport = { commits += "native-offset" }, + placement = { commits += "child-frames" }, + ) + + assertEquals(listOf("native-offset", "child-frames"), commits) + } +} 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/foundation/text/InlineBoxGroupLoweringTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt new file mode 100644 index 000000000..735f15af1 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/InlineBoxGroupLoweringTest.kt @@ -0,0 +1,255 @@ +/* + * 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.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 +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 +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 stylelessClickablePreservesInheritedFontFamilyAndWeight() { + val builder = AnnotatedString.Builder() + builder.withStyle(SpanStyle(fontFamily = FontFamily.SansSerif)) { + append("body ") + withStyle(SpanStyle(fontFamily = FontFamily.Serif, fontWeight = FontWeight.Bold)) { + append("bold") + } + } + val text = builder.toAnnotatedString() + val clickable = LinkAnnotation.Clickable(tag = "body", linkInteractionListener = {}) + val linked = AnnotatedString.Builder(text.length).apply { + append(text) + addLink(clickable, 0, text.length) + }.toAnnotatedString() + + val attr = RichTextAttr() + attr.applyAnnotatedString(linked, density = Density(1f)) + + val spans = attr.getSpans().map { assertIs(it) } + assertEquals("sans-serif", spans[0].spanPropsMap()[TextConst.FONT_FAMILY]) + assertEquals("serif", spans[1].spanPropsMap()[TextConst.FONT_FAMILY]) + assertEquals("700", spans[1].spanPropsMap()[TextConst.FONT_WEIGHT]) + } + + @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( + background = Color.Yellow, + 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]) + 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( + 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, + ), + ), + ), + ) { + 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("serif", child.spanPropsMap()[TextConst.FONT_FAMILY]) + assertEquals("700", child.spanPropsMap()[TextConst.FONT_WEIGHT]) + } + + @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/foundation/text/SelectableTextStylePropsTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/SelectableTextStylePropsTest.kt new file mode 100644 index 000000000..9f849a8c0 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/SelectableTextStylePropsTest.kt @@ -0,0 +1,333 @@ +/* + * 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.foundation.text + +import com.tencent.kuikly.compose.ui.graphics.Color +import com.tencent.kuikly.compose.ui.layout.MeasureScope +import com.tencent.kuikly.compose.ui.node.KNode +import com.tencent.kuikly.compose.ui.node.LayoutNode +import com.tencent.kuikly.compose.ui.node.MeasureScopeWithLayoutNode +import com.tencent.kuikly.compose.ui.text.TextStyle +import com.tencent.kuikly.compose.ui.text.font.FontWeight +import com.tencent.kuikly.compose.ui.text.style.TextAlign +import com.tencent.kuikly.compose.ui.unit.Constraints +import com.tencent.kuikly.compose.ui.unit.Density +import com.tencent.kuikly.compose.ui.unit.IntSize +import com.tencent.kuikly.compose.ui.unit.LayoutDirection +import com.tencent.kuikly.compose.ui.unit.sp +import com.tencent.kuikly.core.base.Size +import com.tencent.kuikly.core.views.SelectableTextAttr +import com.tencent.kuikly.core.views.SelectableTextView +import com.tencent.kuikly.core.views.TextConst +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +/** + * The resolver is the contract between Compose style values and the native + * prop wire format shared by all three renderers and the measuring shadow. + * Because the backing compose node is reusable, every supported field must + * always resolve to a concrete wire value — these teeth pin both the + * mapping and the reset-on-reuse behavior. + */ +class SelectableTextStylePropsTest { + + @Test + fun reusableNodeMeasuresTheActualKNodeViewAfterTextAndStyleChange() { + val actualView = RecordingSelectableTextView() + val detachedRememberedView = RecordingSelectableTextView() + val scope = TestMeasureScope(KNode(actualView)) + val constraints = Constraints( + minWidth = 636, + maxWidth = 636, + minHeight = 0, + maxHeight = Constraints.Infinity, + ) + val policy = selectableTextMeasurePolicy { measuredView -> + assertSame(actualView, measuredView) + 3f + } + + actualView.getViewAttr().text("A") + resolveSelectableTextStyleProps( + TextStyle(color = Color.Red, fontSize = 20.sp), + densityScale = 1f, + ).applyTo(actualView.getViewAttr()) + actualView.nextHeight = 24f + val first = with(policy) { scope.measure(emptyList(), constraints) } + assertEquals(72, first.height) + + // Model a Lazy reusable slot retaining this KNode while an ordinary + // remembered object is recreated for the replacement content. + actualView.getViewAttr().text("B") + resolveSelectableTextStyleProps(TextStyle.Default, densityScale = 1f) + .applyTo(actualView.getViewAttr()) + actualView.nextHeight = 40f + val reused = with(policy) { scope.measure(emptyList(), constraints) } + assertEquals(120, reused.height) + assertEquals(2, actualView.requests.size) + assertEquals(0, detachedRememberedView.requests.size) + assertSame(actualView, selectableTextViewForMeasure(scope)) + + // Explicit intrinsic implementations must keep the original node-aware + // receiver instead of re-entering normal measure through a wrapper that + // has lost MeasureScopeWithLayoutNode. + val intrinsicHeight = with(policy) { scope.minIntrinsicHeight(emptyList(), 636) } + assertEquals(120, intrinsicHeight) + assertEquals(212f to -1f, actualView.requests.last()) + + val intrinsicWidth = with(policy) { scope.maxIntrinsicWidth(emptyList(), 120) } + assertEquals(636, intrinsicWidth) + assertEquals(100000f to 40f, actualView.requests.last()) + assertEquals(4, actualView.requests.size) + assertEquals(0, detachedRememberedView.requests.size) + } + + @Test + fun unboundedLazyColumnHeightUsesShadowSentinelAndFiniteContentHeight() { + val constraints = Constraints( + minWidth = 636, + maxWidth = 636, + minHeight = 0, + maxHeight = Constraints.Infinity, + ) + + assertEquals( + -1f, + selectableTextShadowConstraint( + maxDimension = constraints.maxHeight, + pagerDensity = 3f, + unboundedValue = -1f, + ) + ) + val measured = + selectableTextMeasuredSize( + constraints = constraints, + measuredWidth = 212f, + measuredHeight = 24f, + pagerDensity = 3f, + ) + assertEquals(IntSize(width = 636, height = 72), measured) + assertNotEquals(Constraints.Infinity, measured.height) + } + + @Test + fun boundedDimensionsAreConvertedToShadowUnitsAndConstrainedBack() { + val constraints = Constraints( + minWidth = 300, + maxWidth = 600, + minHeight = 40, + maxHeight = 240, + ) + + assertEquals( + 200f, + selectableTextShadowConstraint( + maxDimension = constraints.maxWidth, + pagerDensity = 3f, + unboundedValue = 100000f, + ) + ) + assertEquals( + 80f, + selectableTextShadowConstraint( + maxDimension = constraints.maxHeight, + pagerDensity = 3f, + unboundedValue = -1f, + ) + ) + assertEquals( + IntSize(width = 300, height = 240), + selectableTextMeasuredSize( + constraints = constraints, + measuredWidth = 80f, + measuredHeight = 100f, + pagerDensity = 3f, + ) + ) + } + + private class RecordingSelectableTextView : SelectableTextView() { + var nextWidth = 212f + var nextHeight = 0f + val requests = mutableListOf>() + + override fun calculateContentSize(maxWidth: Float, maxHeight: Float): Size { + requests += maxWidth to maxHeight + return Size(nextWidth, nextHeight) + } + } + + private class TestMeasureScope( + override val layoutNode: LayoutNode, + ) : MeasureScopeWithLayoutNode, Density by Density(1f) { + override val layoutDirection: LayoutDirection = LayoutDirection.Ltr + } + + private val defaultProps = SelectableTextStyleProps( + color = Color.Black.toKuiklyColor().toString(), + fontSize = SELECTABLE_TEXT_DEFAULT_FONT_SIZE, + fontWeight = "400", + lineHeight = SELECTABLE_TEXT_DEFAULT_FONT_SIZE * SELECTABLE_TEXT_DEFAULT_LINE_HEIGHT_FACTOR, + textAlign = "left", + ) + + @Test + fun unspecifiedStyleResolvesToConcreteDefaultsForEveryField() { + assertEquals( + defaultProps, + resolveSelectableTextStyleProps(TextStyle.Default, densityScale = 1f) + ) + } + + private val styledStyle = TextStyle( + color = Color.Red, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + lineHeight = 30.sp, + textAlign = TextAlign.Right, + ) + + private fun assertAttrHoldsStyledValues(attr: SelectableTextAttr) { + assertEquals(Color.Red.toKuiklyColor().toString(), attr.getProp(TextConst.TEXT_COLOR)) + assertEquals(20f, attr.getProp(TextConst.FONT_SIZE)) + assertEquals("700", attr.getProp(TextConst.FONT_WEIGHT)) + assertEquals(30f, attr.getProp(TextConst.LINE_HEIGHT)) + assertEquals("right", attr.getProp(TextConst.TEXT_ALIGN)) + } + + private fun assertAttrHoldsDefaultValues(attr: SelectableTextAttr) { + assertEquals(defaultProps.color, attr.getProp(TextConst.TEXT_COLOR)) + assertEquals(defaultProps.fontSize, attr.getProp(TextConst.FONT_SIZE)) + assertEquals(defaultProps.fontWeight, attr.getProp(TextConst.FONT_WEIGHT)) + assertEquals(defaultProps.lineHeight, attr.getProp(TextConst.LINE_HEIGHT)) + assertEquals(defaultProps.textAlign, attr.getProp(TextConst.TEXT_ALIGN)) + } + + @Test + fun applyToWritesStyledThenDefaultThenStyledThroughTheProductionAttrPath() { + // The reusable-node sequence through the REAL write path: the same + // SelectableTextAttr receives applyTo for a fully styled update, then + // TextStyle.Default, then styled again. Every supported wire key must + // be overwritten on each step — a missed key in applyTo fails here. + val attr = SelectableTextAttr() + + resolveSelectableTextStyleProps(styledStyle, densityScale = 1f).applyTo(attr) + assertAttrHoldsStyledValues(attr) + + resolveSelectableTextStyleProps(TextStyle.Default, densityScale = 1f).applyTo(attr) + assertAttrHoldsDefaultValues(attr) + + resolveSelectableTextStyleProps(styledStyle, densityScale = 1f).applyTo(attr) + assertAttrHoldsStyledValues(attr) + } + + @Test + fun propPairSequenceAuxiliaryCheckResetsEveryKey() { + // Auxiliary wire-level view of the same sequence (kept in addition to, + // not instead of, the attr-path test above). + val propStore = mutableMapOf() + resolveSelectableTextStyleProps(styledStyle, densityScale = 1f) + .asPropPairs().forEach { (key, value) -> propStore[key] = value } + resolveSelectableTextStyleProps(TextStyle.Default, densityScale = 1f) + .asPropPairs().forEach { (key, value) -> propStore[key] = value } + + val expected: Map = mapOf( + TextConst.TEXT_COLOR to defaultProps.color, + TextConst.FONT_SIZE to defaultProps.fontSize, + TextConst.FONT_WEIGHT to defaultProps.fontWeight, + TextConst.LINE_HEIGHT to defaultProps.lineHeight, + TextConst.TEXT_ALIGN to defaultProps.textAlign, + ) + assertEquals(expected, propStore) + } + + @Test + fun propPairsAlwaysCoverTheFullWireContract() { + val keys = resolveSelectableTextStyleProps(TextStyle.Default, densityScale = 1f) + .asPropPairs().map { it.first } + assertEquals( + listOf( + TextConst.TEXT_COLOR, + TextConst.FONT_SIZE, + TextConst.FONT_WEIGHT, + TextConst.LINE_HEIGHT, + TextConst.TEXT_ALIGN, + ), + keys + ) + } + + @Test + fun colorResolvesToKuiklyColorString() { + val props = resolveSelectableTextStyleProps( + TextStyle(color = Color.Red), + densityScale = 1f + ) + assertEquals(Color.Red.toKuiklyColor().toString(), props.color) + } + + @Test + fun fontSizeAndLineHeightScaleWithDensity() { + val props = resolveSelectableTextStyleProps( + TextStyle(fontSize = 16.sp, lineHeight = 24.sp), + densityScale = 1.5f + ) + assertEquals(24f, props.fontSize) + assertEquals(36f, props.lineHeight) + } + + @Test + fun unspecifiedLineHeightFollowsResolvedFontSize() { + val props = resolveSelectableTextStyleProps( + TextStyle(fontSize = 18.sp), + densityScale = 1f + ) + assertEquals(18f, props.fontSize) + assertEquals(18f * SELECTABLE_TEXT_DEFAULT_LINE_HEIGHT_FACTOR, props.lineHeight) + } + + @Test + fun fontWeightBucketsMatchNativeWeightStrings() { + fun weightProp(weight: FontWeight): String = resolveSelectableTextStyleProps( + TextStyle(fontWeight = weight), + densityScale = 1f + ).fontWeight + + assertEquals("400", weightProp(FontWeight.W300)) + assertEquals("400", weightProp(FontWeight.Normal)) + assertEquals("500", weightProp(FontWeight.Medium)) + assertEquals("600", weightProp(FontWeight.SemiBold)) + assertEquals("700", weightProp(FontWeight.Bold)) + assertEquals("700", weightProp(FontWeight.W900)) + } + + @Test + fun textAlignMapsToNativeAlignKeywords() { + fun alignProp(align: TextAlign): String = resolveSelectableTextStyleProps( + TextStyle(textAlign = align), + densityScale = 1f + ).textAlign + + assertEquals("left", alignProp(TextAlign.Left)) + assertEquals("left", alignProp(TextAlign.Start)) + assertEquals("left", alignProp(TextAlign.Justify)) + assertEquals("center", alignProp(TextAlign.Center)) + assertEquals("right", alignProp(TextAlign.Right)) + assertEquals("right", alignProp(TextAlign.End)) + } +} 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..25577853a --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/foundation/text/TextInputCallbackArbiterTest.kt @@ -0,0 +1,493 @@ +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 markedCompleteStateKeepsCompositionAndConsumesFollowingLegacyEcho() { + val arbiter = TextInputCallbackArbiter() + val markedState = TextInputState( + text = "english", + selectionStart = 7, + selectionEnd = 7, + compositionStart = 0, + compositionEnd = 7, + ) + + val complete = arbiter.onCompleteState(markedState) + + assertEquals(TextRange(0, 7), complete.composition) + assertNull(arbiter.onLegacyTextChange(markedState.text, markedState)) + } + + @Test + fun largeMarkedImeCommitPublishesFinalSelectionWithoutControlledWriteback() { + val callbackArbiter = TextInputCallbackArbiter() + val controlledStateArbiter = TextInputControlledStateArbiter() + val markedText = "p".repeat(31) + val markedState = TextInputState( + text = markedText, + selectionStart = markedText.length, + selectionEnd = markedText.length, + compositionStart = 0, + compositionEnd = markedText.length, + ) + + val markedValue = callbackArbiter.onCompleteState(markedState) + assertEquals(TextRange(0, 31), markedValue.composition) + assertNull(callbackArbiter.onLegacyTextChange(markedText, markedState)) + + // KRTextAreaView publishes this complete candidate-commit state before the legacy text + // callback. The legacy echo is consumed instead of transiently exposing selection=0. + val committedState = state(text = "中文输", selection = 3) + val committedValue = callbackArbiter.onCompleteState(committedState) + controlledStateArbiter.recordNativeValue(committedValue) + + assertEquals(3, committedValue.text.length) + assertEquals(TextRange(3), committedValue.selection) + assertNull(committedValue.composition) + assertNull(callbackArbiter.onLegacyTextChange(committedValue.text, committedState)) + assertTrue( + controlledStateArbiter.shouldSuppressControlledUpdate( + value = committedValue, + ), + ) + } + + @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) + arbiter.recordNativeValue(firstNativeValue) + arbiter.recordNativeValue(secondNativeValue) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + ), + ) + } + + @Test + fun staleNativeIdentityCannotRollbackNewerNativeEditingState() { + val scenarios = listOf( + "Android selection" to Pair( + TextFieldValue(text = "a", selection = TextRange(1)), + TextFieldValue(text = "ab", selection = TextRange(2)), + ), + "iOS marked text" to Pair( + TextFieldValue( + text = "n", + selection = TextRange(1), + composition = TextRange(0, 1), + ), + TextFieldValue( + text = "ni", + selection = TextRange(2), + composition = TextRange(0, 2), + ), + ), + "OHOS full editing state" to Pair( + TextFieldValue( + text = "中", + selection = TextRange(0, 1), + composition = TextRange(0, 1), + ), + TextFieldValue( + text = "中文", + selection = TextRange(1, 2), + composition = TextRange(0, 2), + ), + ), + ) + + scenarios.forEach { (platform, nativeValues) -> + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = nativeValues.first + val latestNativeValue = nativeValues.second + arbiter.recordNativeValue(firstNativeValue) + arbiter.recordNativeValue(latestNativeValue) + + // The caller can still expose an earlier exact callback object after native state has + // advanced. That object is still a direct echo of the older native snapshot and + // must not write text, selection, or composition back over the latest editor state. + val shouldSuppress = arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ) + var survivingNativeValue = latestNativeValue + if (!shouldSuppress) { + survivingNativeValue = firstNativeValue + } + + assertTrue(shouldSuppress, "$platform stale native token must be fenced") + assertEquals( + latestNativeValue, + survivingNativeValue, + "$platform latest text/selection/composition must survive", + ) + + val laterText = latestNativeValue.text + "!" + val laterNativeValue = TextFieldValue( + text = laterText, + selection = TextRange(laterText.length), + composition = latestNativeValue.composition?.let { + TextRange(it.start, laterText.length) + }, + ) + arbiter.recordNativeValue(laterNativeValue) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + "$platform stale token must remain fenced across multiple native callbacks", + ) + + val lateArbiter = TextInputControlledStateArbiter() + lateArbiter.recordNativeValue(firstNativeValue) + lateArbiter.recordNativeValue(latestNativeValue) + assertTrue( + lateArbiter.shouldSuppressControlledUpdate( + value = latestNativeValue, + ), + "$platform latest direct echo must not write back", + ) + assertTrue( + lateArbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + "$platform late stale token must stay fenced after the latest echo", + ) + } + } + + @Test + fun newControlledEditingStateRemainsAuthoritativeAfterLatestNativeSnapshot() { + val arbiter = TextInputControlledStateArbiter() + val nativeValue = TextFieldValue( + text = "marked", + selection = TextRange(6), + composition = TextRange(0, 6), + ) + arbiter.recordNativeValue(nativeValue) + + val externalReplacement = TextFieldValue( + text = nativeValue.text, + selection = TextRange(1, 4), + composition = null, + ) + + assertFalse(nativeValue === externalReplacement) + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = externalReplacement, + ), + ) + } + + @Test + fun coalescedLatestEchoKeepsEarlierDirectEchoToken() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "1234567", selection = 7) + val secondNativeValue = value(text = "123456789", selection = 9) + arbiter.recordNativeValue(firstNativeValue) + arbiter.recordNativeValue(secondNativeValue) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + ) + } + + @Test + fun legacyZeroSelectionEchoIsNotWrittenBackToNative() { + val arbiter = TextInputControlledStateArbiter() + val nativeValue = value(text = "1234567", selection = 0) + arbiter.recordNativeValue(nativeValue) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = nativeValue, + ), + ) + } + + @Test + fun transformedControlledValueRemainsAuthoritative() { + val arbiter = TextInputControlledStateArbiter() + arbiter.recordNativeValue(value(text = "draft", selection = 5)) + + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = value(text = "DRAFT", selection = 5), + ), + ) + } + + @Test + fun distinctEquivalentHistoricalBusinessValueIsNotSuppressedByIdentityFence() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "draft", selection = 1) + val secondNativeValue = value(text = "draft", selection = 2) + arbiter.recordNativeValue(firstNativeValue) + arbiter.recordNativeValue(secondNativeValue) + + val normalizedBusinessValue = value(text = "draft", selection = 1) + + assertEquals(firstNativeValue, normalizedBusinessValue) + assertFalse(firstNativeValue === normalizedBusinessValue) + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = normalizedBusinessValue, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + ), + ) + } + + @Test + fun retainedHistoricalNativeInstanceIsFailClosedInMountedSession() { + val arbiter = TextInputControlledStateArbiter() + val firstNativeValue = value(text = "draft", selection = 1) + val secondNativeValue = value(text = "draft", selection = 2) + arbiter.recordNativeValue(firstNativeValue) + arbiter.recordNativeValue(secondNativeValue) + + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = firstNativeValue, + ), + ) + + // Reusing the exact native callback instance is ambiguous after native state advances. + // A distinguishable business replacement remains authoritative as a new object/editing state. + val explicitBusinessReplacement = firstNativeValue.copy(selection = TextRange(0, 1)) + assertFalse(firstNativeValue === explicitBusinessReplacement) + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = explicitBusinessReplacement, + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = secondNativeValue, + ), + ) + } + + @Test + fun remountedSessionCannotMatchPreviousSessionToken() { + val oldSessionArbiter = TextInputControlledStateArbiter() + val oldSessionValue = value(text = "draft", selection = 5) + oldSessionArbiter.recordNativeValue(oldSessionValue) + + // CoreTextField remembers the arbiter inside the editor session, so a true remount creates + // a fresh token scope rather than trying to infer session identity from event ordering. + val remountedSessionArbiter = TextInputControlledStateArbiter() + + assertFalse( + remountedSessionArbiter.shouldSuppressControlledUpdate( + value = oldSessionValue, + ), + ) + } + + @Test + fun nativeIdentityProvenanceWindowIsBounded() { + val arbiter = TextInputControlledStateArbiter() + val nativeValues = (0..64).map { index -> + val text = "draft-$index" + value(text = text, selection = text.length) + } + nativeValues.forEach(arbiter::recordNativeValue) + // Retain enough history to cover rapid snapshot convergence without retaining every text + // object for the full lifetime of a long-lived editor. + assertFalse( + arbiter.shouldSuppressControlledUpdate( + value = nativeValues.first(), + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = nativeValues[1], + ), + ) + assertTrue( + arbiter.shouldSuppressControlledUpdate( + value = nativeValues.last(), + ), + ) + } + + 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), + ) +} 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..9ef7fc1e6 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/KuiklyScrollInfoTest.kt @@ -0,0 +1,121 @@ +/* + * 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.assertEquals +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) + } + + // 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) + ) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/ScrollViewBindingGateTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/ScrollViewBindingGateTest.kt new file mode 100644 index 000000000..19117007d --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/gestures/ScrollViewBindingGateTest.kt @@ -0,0 +1,72 @@ +package com.tencent.kuikly.compose.gestures + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest + +class ScrollViewBindingGateTest { + @Test + fun layoutReadyWithoutBindingDoesNotSubmitUntilCurrentHandleArrives() = runTest { + val gate = ScrollViewBindingGate() + val submitted = CompletableDeferred() + var calls = 0 + val job = launch(start = CoroutineStart.UNDISPATCHED) { + gate.withCurrentBinding { handle -> + calls += 1 + submitted.complete(handle) + } + } + + assertEquals(0, calls) + val current = FakeHandle("current") + gate.update(current) + + assertSame(current, submitted.await()) + assertEquals(1, calls) + job.join() + } + + @Test + fun cancellationBeforeBindingProducesZeroSubmission() = runTest { + val gate = ScrollViewBindingGate() + var calls = 0 + val job = launch(start = CoroutineStart.UNDISPATCHED) { + gate.withCurrentBinding { calls += 1 } + } + + job.cancelAndJoin() + gate.update(FakeHandle("late")) + + assertEquals(0, calls) + } + + @Test + fun clearedOldBindingCannotReceiveSuccessorSubmission() = runTest { + val gate = ScrollViewBindingGate() + val old = FakeHandle("old") + val current = FakeHandle("current") + gate.update(old) + gate.update(null) + gate.update(current) + + var submitted: FakeHandle? = null + gate.withCurrentBinding { + submitted = it + it.calls += 1 + } + + assertSame(current, submitted) + assertEquals(0, old.calls) + assertEquals(1, current.calls) + } + + private data class FakeHandle( + val name: String, + var calls: Int = 0 + ) +} 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..b5d1a74d0 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/material3/PullToRefreshStateTest.kt @@ -0,0 +1,261 @@ +/* + * 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 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 + 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 + ) + ) + } + + @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/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryTest.kt new file mode 100644 index 000000000..137fa602a --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerCompositionStateRegistryTest.kt @@ -0,0 +1,237 @@ +/* + * 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.profiler + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ProfilerCompositionStateRegistryTest { + + private var currentThreadId = 1L + + private fun registry(): ProfilerCompositionStateRegistry = + ProfilerCompositionStateRegistry { currentThreadId } + + @Test + fun noObserverContextRequiresCoarseFallback() { + val snapshot = registry().currentScopeSnapshot() + + assertFalse(snapshot.hasPreciseMapping) + assertNull(snapshot.scope) + assertNull(snapshot.triggerStateObjects) + assertFalse(snapshot.isForcedRecomposition) + } + + @Test + fun endingCompositionBDoesNotClearCompositionA() { + val registry = registry() + val stateA = Any() + val stateB = Any() + val passA = registry.beginComposition("composition-a", mapOf("scope-a" to setOf(stateA))) + assertTrue(registry.registerHandle("composition-a", passA.generation, "handle-a")) + registry.beginScope("composition-a", passA.generation, "scope-a") + + currentThreadId = 2L + val passB = registry.beginComposition("composition-b", mapOf("scope-b" to setOf(stateB))) + assertTrue(registry.registerHandle("composition-b", passB.generation, "handle-b")) + registry.beginScope("composition-b", passB.generation, "scope-b") + assertEquals("scope-b", registry.currentScopeSnapshot().scope) + + assertEquals(listOf("handle-b"), registry.endComposition("composition-b")) + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + + currentThreadId = 1L + val snapshotA = registry.currentScopeSnapshot() + assertEquals("scope-a", snapshotA.scope) + assertEquals(setOf(stateA), snapshotA.triggerStateObjects) + assertTrue(snapshotA.hasPreciseMapping) + assertEquals(listOf("handle-a"), registry.endComposition("composition-a")) + } + + @Test + fun activeScopesArePartitionedByExecutionThread() { + val registry = registry() + val pass = registry.beginComposition( + "composition", + mapOf("scope-a" to emptySet(), "scope-b" to emptySet()) + ) + + registry.beginScope("composition", pass.generation, "scope-a") + assertEquals("scope-a", registry.currentScopeSnapshot().scope) + + currentThreadId = 2L + registry.beginScope("composition", pass.generation, "scope-b") + assertEquals("scope-b", registry.currentScopeSnapshot().scope) + registry.endScope("composition", pass.generation, "scope-b") + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + + currentThreadId = 1L + assertEquals("scope-a", registry.currentScopeSnapshot().scope) + } + + @Test + fun nestedAndOutOfOrderScopeEndsKeepTheCurrentScopeStable() { + val registry = registry() + val pass = registry.beginComposition( + "composition", + mapOf("outer" to emptySet(), "inner" to emptySet()) + ) + registry.beginScope("composition", pass.generation, "outer") + registry.beginScope("composition", pass.generation, "inner") + + registry.endScope("composition", pass.generation, "outer") + assertEquals("inner", registry.currentScopeSnapshot().scope) + + registry.endScope("composition", pass.generation, "inner") + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + + @Test + fun staleGenerationCallbacksAndHandlesCannotTouchRestartedPass() { + val registry = registry() + val first = registry.beginComposition("composition", mapOf("old" to emptySet())) + assertTrue(registry.registerHandle("composition", first.generation, "old-handle")) + registry.beginScope("composition", first.generation, "old") + + val second = registry.beginComposition("composition", mapOf("new" to emptySet())) + assertEquals(listOf("old-handle"), second.handlesToDispose) + assertFalse(registry.registerHandle("composition", first.generation, "late-old-handle")) + registry.beginScope("composition", first.generation, "old") + registry.beginScope("composition", second.generation, "new") + + registry.endScope("composition", first.generation, "new") + registry.scopeDisposed("composition", first.generation, "new") + assertEquals("new", registry.currentScopeSnapshot().scope) + + assertTrue(registry.registerHandle("composition", second.generation, "new-handle")) + assertEquals(listOf("new-handle"), registry.endComposition("composition")) + } + + @Test + fun forcedRecompositionIsDistinctFromAnUnmappedObservedScope() { + val registry = registry() + val pass = registry.beginComposition("composition", mapOf("forced" to null)) + registry.beginScope("composition", pass.generation, "forced") + + val forced = registry.currentScopeSnapshot() + assertTrue(forced.hasPreciseMapping) + assertTrue(forced.isForcedRecomposition) + assertNull(forced.triggerStateObjects) + + registry.endScope("composition", pass.generation, "forced") + registry.beginScope("composition", pass.generation, "unmapped-child") + val unmapped = registry.currentScopeSnapshot() + assertTrue(unmapped.hasPreciseMapping) + assertFalse(unmapped.isForcedRecomposition) + assertNull(unmapped.triggerStateObjects) + } + + @Test + fun invalidationStatesAreDefensivelySnapshotted() { + val registry = registry() + val mutableStates = mutableSetOf(Any()) + val originalState = mutableStates.single() + val pass = registry.beginComposition("composition", mapOf("scope" to mutableStates)) + mutableStates.clear() + mutableStates.add(Any()) + registry.beginScope("composition", pass.generation, "scope") + + val snapshot = registry.currentScopeSnapshot() + assertEquals(1, snapshot.triggerStateObjects?.size) + assertSame(originalState, snapshot.triggerStateObjects?.single()) + } + + @Test + fun disposingScopeRemovesOnlyThatScopeFromEveryThread() { + val registry = registry() + val pass = registry.beginComposition( + "composition", + mapOf("scope-a" to emptySet(), "scope-b" to emptySet()) + ) + registry.beginScope("composition", pass.generation, "scope-a") + + currentThreadId = 2L + registry.beginScope("composition", pass.generation, "scope-b") + registry.scopeDisposed("composition", pass.generation, "scope-a") + assertEquals("scope-b", registry.currentScopeSnapshot().scope) + + currentThreadId = 1L + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + + @Test + fun disposeAllDetachesAllHandlesAndRejectsLateCallbacks() { + val registry = registry() + val passA = registry.beginComposition("composition-a", mapOf("scope-a" to emptySet())) + assertTrue(registry.registerHandle("composition-a", passA.generation, "handle-a")) + registry.beginScope("composition-a", passA.generation, "scope-a") + + currentThreadId = 2L + val passB = registry.beginComposition("composition-b", mapOf("scope-b" to emptySet())) + assertTrue(registry.registerHandle("composition-b", passB.generation, "handle-b")) + registry.beginScope("composition-b", passB.generation, "scope-b") + + assertEquals(setOf("handle-a", "handle-b"), registry.disposeAll().toSet()) + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + assertFalse(registry.registerHandle("composition-a", passA.generation, "late-handle")) + registry.beginScope("composition-b", passB.generation, "scope-b") + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + + @Test + fun detachedHandleCanReenterRegistryDuringLockFreeDisposal() { + val registry = registry() + var reentered = false + val handle = ReentrantHandle { + reentered = true + val passB = registry.beginComposition("composition-b", mapOf("scope-b" to emptySet())) + registry.beginScope("composition-b", passB.generation, "scope-b") + } + val first = registry.beginComposition("composition-a", mapOf("scope-a" to emptySet())) + assertTrue(registry.registerHandle("composition-a", first.generation, handle)) + + val restarted = registry.beginComposition("composition-a", mapOf("scope-a" to emptySet())) + assertEquals(1, restarted.handlesToDispose.size) + restarted.handlesToDispose.single().dispose() + + assertTrue(reentered) + assertEquals("scope-b", registry.currentScopeSnapshot().scope) + } + + @Test + fun rapidBeginEndRestartLifecycleLeavesNoBorrowedContext() { + val registry = registry() + + repeat(1_000) { index -> + currentThreadId = (index % 3).toLong() + 1L + val scope = "scope-$index" + val pass = registry.beginComposition("composition", mapOf(scope to emptySet())) + registry.beginScope("composition", pass.generation, scope) + assertEquals(scope, registry.currentScopeSnapshot().scope) + registry.endScope("composition", pass.generation, scope) + assertTrue(registry.endComposition("composition").isEmpty()) + assertFalse(registry.currentScopeSnapshot().hasPreciseMapping) + } + } + + private class ReentrantHandle(private val onDispose: () -> Unit) { + fun dispose() = onDispose() + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileOutputLifecycleTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileOutputLifecycleTest.kt new file mode 100644 index 000000000..7e1fedf5c --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/profiler/ProfilerFileOutputLifecycleTest.kt @@ -0,0 +1,519 @@ +/* + * 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.profiler + +import com.tencent.kuikly.compose.profiler.output.FileOutputStrategy +import com.tencent.kuikly.compose.profiler.output.ProfilerFileIoDispatcher +import com.tencent.kuikly.compose.profiler.output.ProfilerFileIoResult +import com.tencent.kuikly.compose.profiler.output.ProfilerFileOperation +import com.tencent.kuikly.compose.profiler.output.ProfilerFileOperationKind +import com.tencent.kuikly.core.module.FileModule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ProfilerFileOutputLifecycleTest { + + @Test + fun liveModuleRegistryFallsBackWhenThePreferredPagerIsDestroyed() { + val registry = ProfilerFileModuleRegistry() + val moduleA = fileModule("pager-a") + val moduleB = fileModule("pager-b") + + assertSame(moduleA, registry.register(moduleA)) + assertSame(moduleB, registry.register(moduleB)) + assertEquals(2, registry.size()) + assertSame(moduleB, registry.current()) + + assertSame(moduleA, registry.unregister(moduleB)) + assertEquals(1, registry.size()) + assertSame(moduleA, registry.current()) + + assertNull(registry.unregister(moduleA)) + assertEquals(0, registry.size()) + assertNull(registry.current()) + } + + @Test + fun registeringTheSamePagerPromotesItWithoutDuplicatingOwnership() { + val registry = ProfilerFileModuleRegistry() + val moduleA = fileModule("pager-a") + val moduleB = fileModule("pager-b") + + registry.register(moduleA) + registry.register(moduleB) + registry.register(moduleA) + + assertEquals(2, registry.size()) + assertSame(moduleA, registry.current()) + assertSame(moduleB, registry.unregister(moduleA)) + } + + @Test + fun stalePagerCancellationRetriesTheSameWriteOnTheLiveFallback() { + val moduleA = fileModule("pager-a") + val moduleB = fileModule("pager-b") + var currentModule: FileModule? = moduleA + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ currentModule }, dispatcher) + + strategy.activate("session-a", 100L) + assertDispatch(dispatcher.dispatches[0], moduleA, ProfilerFileOperationKind.WRITE, "profiler_frames.jsonl") + assertTrue(dispatcher.dispatches[0].operation.content.contains("session-a")) + + currentModule = moduleB + dispatcher.complete(0, ProfilerFileIoResult.RetryableFailure("pager bridge unavailable")) + + assertEquals(2, dispatcher.dispatches.size) + assertSame(dispatcher.dispatches[0].operation, dispatcher.dispatches[1].operation) + assertSame(moduleB, dispatcher.dispatches[1].module) + + dispatcher.complete(1, ProfilerFileIoResult.Success) + assertEquals(3, dispatcher.dispatches.size) + assertDispatch(dispatcher.dispatches[2], moduleB, ProfilerFileOperationKind.WRITE, "profiler_report.json") + assertEquals("", dispatcher.dispatches[2].operation.content) + dispatcher.complete(2, ProfilerFileIoResult.Success) + + strategy.deactivate(report("session-a", 100L)) + assertEquals(4, dispatcher.dispatches.size) + assertDispatch(dispatcher.dispatches[3], moduleB, ProfilerFileOperationKind.WRITE, "profiler_report.json") + assertTrue(dispatcher.dispatches[3].operation.content.contains("\"sessionId\":\"session-a\"")) + } + + @Test + fun moduleChangeRecoversAnInFlightFrameWriteWhoseCallbackNeverReturns() { + val moduleA = fileModule("pager-a") + val moduleB = fileModule("pager-b") + var currentModule: FileModule? = moduleA + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ currentModule }, dispatcher) + val completions = mutableListOf() + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + assertFalse( + dispatcher.dispatches[0].operation.operationId == + dispatcher.dispatches[1].operation.operationId + ) + dispatcher.complete(1, ProfilerFileIoResult.Success) + + strategy.onFrameComplete(frame(frameId = 1L, startTimestampMs = 150L)) + strategy.writeReport(report("session-a", 100L), completions::add) + + assertEquals(3, dispatcher.dispatches.size) + val abandonedAppend = dispatcher.dispatches[2] + assertDispatch( + abandonedAppend, + moduleA, + ProfilerFileOperationKind.APPEND, + "profiler_frames.jsonl" + ) + assertTrue(completions.isEmpty()) + + // The old Pager disappears after native accepted the append but before its callback can + // cross the destroyed bridge. Merely clearing blockedFileModule must not leave the global + // queue permanently pinned behind that callback. + currentModule = moduleB + strategy.onFileModuleChanged() + + assertEquals(4, dispatcher.dispatches.size) + val repairedFrames = dispatcher.dispatches[3] + assertDispatch( + repairedFrames, + moduleB, + ProfilerFileOperationKind.APPEND, + "profiler_frames.jsonl" + ) + assertSame(abandonedAppend.operation, repairedFrames.operation) + assertEquals(abandonedAppend.operation.operationId, repairedFrames.operation.operationId) + assertTrue(repairedFrames.operation.content.contains("\"frameId\":1")) + assertTrue(completions.isEmpty()) + + // The old callback can race the retried dispatch. Module identity is part of the in-flight + // token, so A must not complete the same operation object currently owned by B. + dispatcher.complete(2, ProfilerFileIoResult.Success) + assertEquals(4, dispatcher.dispatches.size) + assertTrue(completions.isEmpty()) + + dispatcher.complete(3, ProfilerFileIoResult.Success) + assertEquals(5, dispatcher.dispatches.size) + assertDispatch( + dispatcher.dispatches[4], + moduleB, + ProfilerFileOperationKind.WRITE, + "profiler_report.json" + ) + dispatcher.complete(4, ProfilerFileIoResult.Success) + + assertEquals( + listOf( + RecompositionProfilerFileOutputResult.Success("session-a") + ), + completions + ) + assertEquals(5, dispatcher.dispatches.size) + assertEquals(1, completions.size) + assertEquals(4, dispatcher.dispatches.map { it.operation.operationId }.toSet().size) + } + + @Test + fun queuedWritesWaitForAFileModuleInsteadOfBeingDropped() { + var currentModule: FileModule? = null + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ currentModule }, dispatcher) + + strategy.activate("session-a", 100L) + assertTrue(dispatcher.dispatches.isEmpty()) + + currentModule = fileModule("pager-live") + strategy.onFileModuleChanged() + + assertEquals(1, dispatcher.dispatches.size) + assertSame(currentModule, dispatcher.dispatches.single().module) + assertEquals("profiler_frames.jsonl", dispatcher.dispatches.single().operation.filename) + } + + @Test + fun resetRewritesTheSessionHeaderAndRejectsFramesFromTheOldGeneration() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + + strategy.activate("session-old", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + + strategy.onFrameComplete(frame(frameId = 1L, startTimestampMs = 150L)) + strategy.onSessionReset("session-new", 200L) + + assertEquals(3, dispatcher.dispatches.size) + assertTrue(dispatcher.dispatches[2].operation.content.contains("session-new")) + assertTrue(dispatcher.dispatches[2].operation.content.contains("200")) + dispatcher.complete(2, ProfilerFileIoResult.Success) + dispatcher.complete(3, ProfilerFileIoResult.Success) + + strategy.onFrameComplete(frame(frameId = 2L, startTimestampMs = 150L)) + strategy.onFrameComplete(frame(frameId = 3L, startTimestampMs = 210L)) + strategy.deactivate(report("session-new", 200L)) + + assertEquals(5, dispatcher.dispatches.size) + val append = dispatcher.dispatches[4] + assertDispatch(append, module, ProfilerFileOperationKind.APPEND, "profiler_frames.jsonl") + assertTrue(append.operation.content.contains("\"frameId\":3")) + assertTrue(!append.operation.content.contains("\"frameId\":2")) + dispatcher.complete(4, ProfilerFileIoResult.Success) + + assertEquals(6, dispatcher.dispatches.size) + assertEquals("profiler_report.json", dispatcher.dispatches[5].operation.filename) + assertTrue(dispatcher.dispatches[5].operation.content.contains("session-new")) + } + + @Test + fun reportWritesRemainSerializedAcrossStopAndPostStopExport() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + + strategy.deactivate(report("session-a", 100L)) + strategy.writeReport(report("session-a", 101L)) + + assertEquals(3, dispatcher.dispatches.size) + assertTrue(dispatcher.dispatches[2].operation.content.contains("session-a")) + dispatcher.complete(2, ProfilerFileIoResult.Success) + + assertEquals(4, dispatcher.dispatches.size) + assertTrue(dispatcher.dispatches[3].operation.content.contains("\"startTimestampMs\":101")) + } + + @Test + fun retryableFailureRetriesOnTheSameLiveModuleInsteadOfStalling() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.RetryableFailure("context unavailable")) + + assertEquals(2, dispatcher.dispatches.size) + assertSame(module, dispatcher.dispatches[1].module) + assertSame(dispatcher.dispatches[0].operation, dispatcher.dispatches[1].operation) + + dispatcher.complete(1, ProfilerFileIoResult.Success) + assertEquals(3, dispatcher.dispatches.size) + assertEquals("profiler_report.json", dispatcher.dispatches[2].operation.filename) + } + + @Test + fun retryableFailureExhaustionSurfacesOneTerminalReportFailure() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val completions = mutableListOf() + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.writeReport(report("session-a", 100L), completions::add) + + dispatcher.complete(2, ProfilerFileIoResult.RetryableFailure("context unavailable")) + dispatcher.complete(3, ProfilerFileIoResult.RetryableFailure("context unavailable")) + dispatcher.complete(4, ProfilerFileIoResult.RetryableFailure("context unavailable")) + + assertEquals(5, dispatcher.dispatches.size) + assertEquals(1, completions.size) + val failure = completions.single() + assertTrue(failure is RecompositionProfilerFileOutputResult.Failure) + assertTrue(failure.reason.contains("exhausted after 3 attempts")) + } + + @Test + fun reportCompletionWaitsForQueuedFramesAndNativeReportCommit() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val completions = mutableListOf() + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.onFrameComplete(frame(frameId = 1L, startTimestampMs = 150L)) + strategy.deactivate(report("session-a", 100L), completions::add) + + assertEquals(3, dispatcher.dispatches.size) + assertEquals("profiler_frames.jsonl", dispatcher.dispatches[2].operation.filename) + assertTrue(completions.isEmpty()) + + dispatcher.complete(2, ProfilerFileIoResult.Success) + assertEquals(4, dispatcher.dispatches.size) + assertEquals("profiler_report.json", dispatcher.dispatches[3].operation.filename) + assertTrue(completions.isEmpty()) + + dispatcher.complete(3, ProfilerFileIoResult.Success) + assertEquals( + RecompositionProfilerFileOutputResult.Success("session-a"), + completions.single() + ) + } + + @Test + fun earlierFrameFailureMakesACommittedReportArtifactSetFail() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val completions = mutableListOf() + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.onFrameComplete(frame(frameId = 1L, startTimestampMs = 150L)) + strategy.deactivate(report("session-a", 100L), completions::add) + + dispatcher.complete(2, ProfilerFileIoResult.TerminalFailure("append denied")) + assertTrue(completions.isEmpty()) + dispatcher.complete(3, ProfilerFileIoResult.Success) + + val failure = completions.single() + assertTrue(failure is RecompositionProfilerFileOutputResult.Failure) + assertTrue(failure.reason.contains("earlier profiler file operation failed")) + assertTrue(failure.reason.contains("append denied")) + } + + @Test + fun reportOnlyTerminalFailureCanRetrySuccessfullyInTheSameSession() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val firstCompletions = mutableListOf() + val secondCompletions = mutableListOf() + + strategy.activate("session-a", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + + strategy.writeReport(report("session-a", 100L), firstCompletions::add) + dispatcher.complete(2, ProfilerFileIoResult.TerminalFailure("report denied")) + + assertEquals(1, firstCompletions.size) + val firstFailure = firstCompletions.single() + assertTrue(firstFailure is RecompositionProfilerFileOutputResult.Failure) + assertEquals("report denied", firstFailure.reason) + + strategy.writeReport(report("session-a", 100L), secondCompletions::add) + assertEquals(4, dispatcher.dispatches.size) + assertTrue(secondCompletions.isEmpty()) + dispatcher.complete(3, ProfilerFileIoResult.Success) + + assertEquals(1, firstCompletions.size) + assertEquals( + listOf( + RecompositionProfilerFileOutputResult.Success("session-a") + ), + secondCompletions + ) + } + + @Test + fun newSessionSupersedesOldReportCompletionExactlyOnce() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val oldCompletions = mutableListOf() + val newCompletions = mutableListOf() + + strategy.activate("session-old", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.writeReport(report("session-old", 100L), oldCompletions::add) + + strategy.activate("session-new", 200L) + assertEquals(1, oldCompletions.size) + assertTrue(oldCompletions.single() is RecompositionProfilerFileOutputResult.Failure) + + dispatcher.complete(2, ProfilerFileIoResult.Success) + assertEquals(1, oldCompletions.size) + dispatcher.complete(3, ProfilerFileIoResult.Success) + dispatcher.complete(4, ProfilerFileIoResult.Success) + + strategy.deactivate(report("session-new", 200L), newCompletions::add) + dispatcher.complete(5, ProfilerFileIoResult.Success) + + assertEquals( + RecompositionProfilerFileOutputResult.Success("session-new"), + newCompletions.single() + ) + } + + @Test + fun lateStopFromOldSessionCannotDeactivateTheNewSession() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val oldCompletions = mutableListOf() + val newCompletions = mutableListOf() + + strategy.activate("session-old", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.activate("session-new", 200L) + + strategy.deactivate(report("session-old", 100L), oldCompletions::add) + assertTrue(oldCompletions.single() is RecompositionProfilerFileOutputResult.Failure) + + dispatcher.complete(2, ProfilerFileIoResult.Success) + dispatcher.complete(3, ProfilerFileIoResult.Success) + strategy.onFrameComplete(frame(frameId = 2L, startTimestampMs = 250L)) + strategy.deactivate(report("session-new", 200L), newCompletions::add) + + assertEquals("profiler_frames.jsonl", dispatcher.dispatches[4].operation.filename) + dispatcher.complete(4, ProfilerFileIoResult.Success) + dispatcher.complete(5, ProfilerFileIoResult.Success) + assertEquals( + RecompositionProfilerFileOutputResult.Success("session-new"), + newCompletions.single() + ) + } + + @Test + fun twoConsecutiveSessionsEachReceiveTheirOwnCommitAcknowledgement() { + val module = fileModule("pager-live") + val dispatcher = RecordingDispatcher() + val strategy = FileOutputStrategy({ module }, dispatcher) + val completions = mutableListOf() + + strategy.activate("session-one", 100L) + dispatcher.complete(0, ProfilerFileIoResult.Success) + dispatcher.complete(1, ProfilerFileIoResult.Success) + strategy.deactivate(report("session-one", 100L), completions::add) + assertTrue(completions.isEmpty()) + dispatcher.complete(2, ProfilerFileIoResult.Success) + + strategy.activate("session-two", 200L) + dispatcher.complete(3, ProfilerFileIoResult.Success) + dispatcher.complete(4, ProfilerFileIoResult.Success) + strategy.deactivate(report("session-two", 200L), completions::add) + assertEquals(1, completions.size) + dispatcher.complete(5, ProfilerFileIoResult.Success) + + assertEquals( + listOf( + RecompositionProfilerFileOutputResult.Success("session-one"), + RecompositionProfilerFileOutputResult.Success("session-two") + ), + completions + ) + assertFalse(completions.any { it is RecompositionProfilerFileOutputResult.Failure }) + } + + private fun fileModule(pagerId: String): FileModule = FileModule().also { it.pagerId = pagerId } + + private fun frame(frameId: Long, startTimestampMs: Long): List = + listOf( + RecompositionFrameStartEvent(startTimestampMs, frameId), + RecompositionFrameEndEvent(startTimestampMs + 1L, frameId, durationMs = 1L, recomposedCount = 0) + ) + + private fun report(sessionId: String, startTimestampMs: Long): RecompositionReport = + RecompositionReport( + sessionId = sessionId, + startTimestampMs = startTimestampMs, + durationMs = 10L, + totalFrames = 1L, + totalRecompositions = 1, + composables = emptyList(), + hotspots = emptyList(), + stateChanges = emptyList() + ) + + private fun assertDispatch( + dispatch: RecordingDispatcher.Dispatch, + module: FileModule, + kind: ProfilerFileOperationKind, + filename: String + ) { + assertSame(module, dispatch.module) + assertEquals(kind, dispatch.operation.kind) + assertEquals(filename, dispatch.operation.filename) + } + + private class RecordingDispatcher : ProfilerFileIoDispatcher { + data class Dispatch( + val module: FileModule, + val operation: ProfilerFileOperation, + val completion: (ProfilerFileIoResult) -> Unit + ) + + val dispatches = mutableListOf() + + override fun dispatch( + module: FileModule, + operation: ProfilerFileOperation, + completion: (ProfilerFileIoResult) -> Unit + ) { + dispatches.add(Dispatch(module, operation, completion)) + } + + fun complete(index: Int, result: ProfilerFileIoResult) { + dispatches[index].completion(result) + } + } +} 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..1f6611ff2 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/scroller/ContentSizeExtensionsTest.kt @@ -0,0 +1,411 @@ +/* + * 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 initialNonTopViewportCommitsBeforeFirstPlacement() { + assertEquals( + InitialLazyListNativeViewportAction.Prepare, + initialLazyListNativeViewportAction( + pending = true, + hasItems = true, + isComposeAtTop = false, + contentOffset = 0, + composeOffset = 0, + isDragging = false, + hasScrollView = true, + ) + ) + } + + @Test + fun initialViewportWaitsForItemsAndNativeBinding() { + assertEquals( + InitialLazyListNativeViewportAction.Wait, + initialLazyListNativeViewportAction( + pending = true, + hasItems = false, + isComposeAtTop = false, + contentOffset = 0, + composeOffset = 0, + isDragging = false, + hasScrollView = true, + ) + ) + assertEquals( + InitialLazyListNativeViewportAction.Wait, + initialLazyListNativeViewportAction( + pending = true, + hasItems = true, + isComposeAtTop = false, + contentOffset = 0, + composeOffset = 0, + isDragging = false, + hasScrollView = false, + ) + ) + } + + @Test + fun initialTopOrRestoredViewportNeedsNoNewNativeCommit() { + assertEquals( + InitialLazyListNativeViewportAction.Complete, + initialLazyListNativeViewportAction( + pending = true, + hasItems = true, + isComposeAtTop = true, + contentOffset = 0, + composeOffset = 0, + isDragging = false, + hasScrollView = true, + ) + ) + assertEquals( + InitialLazyListNativeViewportAction.Complete, + initialLazyListNativeViewportAction( + pending = true, + hasItems = true, + isComposeAtTop = false, + contentOffset = 900, + composeOffset = 900, + isDragging = false, + hasScrollView = true, + ) + ) + } + + @Test + fun initialViewportNeverOverridesDragOrRepeats() { + assertEquals( + InitialLazyListNativeViewportAction.Complete, + initialLazyListNativeViewportAction( + pending = true, + hasItems = true, + isComposeAtTop = false, + contentOffset = 0, + composeOffset = 0, + isDragging = true, + hasScrollView = true, + ) + ) + assertEquals( + InitialLazyListNativeViewportAction.Complete, + initialLazyListNativeViewportAction( + pending = false, + hasItems = true, + isComposeAtTop = false, + contentOffset = 0, + composeOffset = 0, + isDragging = false, + hasScrollView = true, + ) + ) + } + + @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() +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchPolicyTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchPolicyTest.kt new file mode 100644 index 000000000..1eef96a65 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/input/pointer/NativeDispatchPolicyTest.kt @@ -0,0 +1,183 @@ +/* + * 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.pointer + +import com.tencent.kuikly.compose.ui.node.NativeDispatchPolicy +import com.tencent.kuikly.compose.ui.node.NativeDispatchPolicy.CAPTURE +import com.tencent.kuikly.compose.ui.node.NativeDispatchPolicy.INHERIT +import com.tencent.kuikly.compose.ui.node.NativeDispatchPolicy.RELEASE +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The native-dispatch capture algebra used by HitPathTracker: the ancestor + * stance flows top-down per branch BEFORE sibling reduction, each root-to-leaf + * path resolves to its deepest non-INHERIT stance, and sibling branches + * combine with any-capture-wins. These teeth pin the barrier-vs-selectable + * region contract, including shared-prefix trees where a capture ancestor + * fans out into release and non-release children. + */ +class NativeDispatchPolicyTest { + + /** + * A test tree node running the PRODUCTION traversal + * (resolveNativeDispatchPolicyTree) — the same function HitPathTracker's + * real hit tree executes; nothing is re-implemented here. + */ + private class PolicyNode( + private val own: NativeDispatchPolicy, + private val children: List = emptyList(), + ) : NativeDispatchPolicyTreeNode { + override val ownNativeDispatchStance: NativeDispatchPolicy get() = own + + override fun forEachPolicyChild(action: (NativeDispatchPolicyTreeNode) -> Unit) { + children.forEach(action) + } + } + + private fun resolve( + node: PolicyNode, + inherited: NativeDispatchPolicy = INHERIT, + ): NativeDispatchPolicy = resolveNativeDispatchPolicyTree(node, inherited) + + private fun rootCaptures(vararg branches: PolicyNode): Boolean = + resolveNativeDispatchPolicyTree( + PolicyNode(INHERIT, branches.toList()), + INHERIT + ) == CAPTURE + + @Test + fun deeperReleaseOverridesItsOwnAncestorCapture() { + // barrier -> plain node -> selectable text release leaf + val chain = PolicyNode(CAPTURE, listOf(PolicyNode(INHERIT, listOf(PolicyNode(RELEASE))))) + assertEquals(RELEASE, resolve(chain)) + assertEquals(false, rootCaptures(chain)) + } + + @Test + fun sharedCaptureAncestorKeepsCapturingForItsNonReleaseChildBranch() { + // The addHitPath shared-prefix shape: one CAPTURE ancestor fans out + // into a RELEASE child branch and an INHERIT child branch. The release + // must only neutralize its own path; the sibling path under the same + // ancestor still resolves to CAPTURE, so the root captures. + val tree = PolicyNode( + CAPTURE, + listOf( + PolicyNode(RELEASE), + PolicyNode(INHERIT), + ) + ) + assertEquals(CAPTURE, resolve(tree)) + assertEquals(true, rootCaptures(tree)) + } + + @Test + fun sharedCaptureAncestorWithAllBranchesReleasedDoesNotCapture() { + val tree = PolicyNode( + CAPTURE, + listOf( + PolicyNode(RELEASE), + PolicyNode(INHERIT, listOf(PolicyNode(RELEASE))), + ) + ) + assertEquals(RELEASE, resolve(tree)) + assertEquals(false, rootCaptures(tree)) + } + + @Test + fun captureWithoutAnyReleaseStaysCaptured() { + val chain = PolicyNode(INHERIT, listOf(PolicyNode(CAPTURE, listOf(PolicyNode(INHERIT))))) + assertEquals(CAPTURE, resolve(chain)) + assertEquals(true, rootCaptures(chain)) + } + + @Test + fun releaseOnOneBranchNeverNeutralizesCaptureOnAnIndependentSiblingBranch() { + val overlayBranch = PolicyNode(CAPTURE) + val releaseBranch = PolicyNode(RELEASE) + assertEquals(true, rootCaptures(overlayBranch, releaseBranch)) + assertEquals(true, rootCaptures(releaseBranch, overlayBranch)) + } + + @Test + fun releaseAloneDoesNotCapture() { + assertEquals(false, rootCaptures(PolicyNode(INHERIT, listOf(PolicyNode(RELEASE))))) + assertEquals(false, rootCaptures(PolicyNode(RELEASE))) + } + + @Test + fun deeperCaptureUnderAReleaseAncestorCapturesSymmetrically() { + val chain = PolicyNode(RELEASE, listOf(PolicyNode(CAPTURE))) + assertEquals(CAPTURE, resolve(chain)) + assertEquals(true, rootCaptures(chain)) + } + + @Test + fun inheritOnlyTreeNeitherCapturesNorReleases() { + val chain = PolicyNode(INHERIT, listOf(PolicyNode(INHERIT))) + assertEquals(INHERIT, resolve(chain)) + assertEquals(false, rootCaptures(chain)) + } + + @Test + fun realModifierNodesDriveTheResolvedPolicies() { + // The actual production nodes, not stand-ins: the barrier node used by + // Modifier.nativeDispatchCapture() and the release node installed by + // SelectableText's Modifier.nativeDispatchRelease(). + assertEquals(CAPTURE, NativeDispatchCaptureNode().resolvedNativeDispatchPolicy()) + assertEquals(RELEASE, NativeDispatchReleaseNode().resolvedNativeDispatchPolicy()) + + // Barrier ancestor with the SelectableText release region on its own + // path: that path releases, so the root must not capture... + val barrierOverText = PolicyNode( + NativeDispatchCaptureNode().resolvedNativeDispatchPolicy(), + listOf(PolicyNode(NativeDispatchReleaseNode().resolvedNativeDispatchPolicy())) + ) + assertEquals(false, rootCaptures(barrierOverText)) + + // ...while the same barrier ancestor fanning out into the release + // region AND a sibling hit (shared prefix) keeps capturing for the + // non-release branch. + val barrierSharedPrefix = PolicyNode( + NativeDispatchCaptureNode().resolvedNativeDispatchPolicy(), + listOf( + PolicyNode(NativeDispatchReleaseNode().resolvedNativeDispatchPolicy()), + PolicyNode(INHERIT), + ) + ) + assertEquals(true, rootCaptures(barrierSharedPrefix)) + } + + @Test + fun sameNodeReleaseDominatesCapture() { + assertEquals( + RELEASE, + combineSameNodeNativeDispatchPolicies(CAPTURE, RELEASE) + ) + assertEquals( + RELEASE, + combineSameNodeNativeDispatchPolicies(RELEASE, CAPTURE) + ) + assertEquals( + CAPTURE, + combineSameNodeNativeDispatchPolicies(INHERIT, CAPTURE) + ) + assertEquals( + INHERIT, + combineSameNodeNativeDispatchPolicies(INHERIT, INHERIT) + ) + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNodeViewDispatchTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNodeViewDispatchTest.kt new file mode 100644 index 000000000..b75f56ef2 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/DrawModifierNodeViewDispatchTest.kt @@ -0,0 +1,97 @@ +/* + * 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.Modifier +import com.tencent.kuikly.compose.ui.graphics.drawscope.CanvasDrawScope +import com.tencent.kuikly.compose.ui.graphics.drawscope.ContentDrawScope +import com.tencent.kuikly.compose.ui.graphics.drawscope.DrawScope +import com.tencent.kuikly.core.base.DeclarativeBaseView +import com.tencent.kuikly.core.views.DivView +import kotlin.test.Test +import kotlin.test.assertEquals + +class DrawModifierNodeViewDispatchTest { + + @Test + fun ordinaryOverrideHasEquivalentOrdinaryAndViewAwareDispatch() { + var ordinaryDraws = 0 + val node = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + ordinaryDraws += 1 + } + } + + with(RecordingContentDrawScope()) { + with(node) { + draw() + draw(DivView()) + } + } + + assertEquals(2, ordinaryDraws) + } + + @Test + fun bareNodePreservesContentInViewAwareDispatch() { + val node = object : Modifier.Node(), DrawModifierNode {} + val drawScope = RecordingContentDrawScope() + + with(drawScope) { + with(node) { + draw(DivView()) + } + } + + assertEquals(1, drawScope.contentDraws) + } + + @Test + fun explicitViewAwareOverrideStillTakesPrecedence() { + var ordinaryDraws = 0 + var viewAwareDraws = 0 + val node = + object : Modifier.Node(), DrawModifierNode { + override fun ContentDrawScope.draw() { + ordinaryDraws += 1 + } + + override fun ContentDrawScope.draw(view: DeclarativeBaseView<*, *>?) { + viewAwareDraws += 1 + } + } + + with(RecordingContentDrawScope()) { + with(node) { + draw(DivView()) + } + } + + assertEquals(0, ordinaryDraws) + assertEquals(1, viewAwareDraws) + } + + private class RecordingContentDrawScope : + ContentDrawScope, + DrawScope by CanvasDrawScope() { + var contentDraws = 0 + + override fun drawContent() { + contentDraws += 1 + } + } +} 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 + } +} diff --git a/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/ViewportOffsetCorrectionTest.kt b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/ViewportOffsetCorrectionTest.kt new file mode 100644 index 000000000..d736decd8 --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/node/ViewportOffsetCorrectionTest.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.compose.ui.node + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ViewportOffsetCorrectionTest { + + @Test + fun shrinkingViewportAdoptsNativeBottomWhenComposeOffsetIsStale() { + assertEquals( + expected = 12_768, + actual = correctedComposeOffsetForViewportChange( + composeOffset = 11_869, + nativeOffset = 12_768, + contentSize = 13_667, + previousViewportSize = 1_774, + newViewportSize = 899, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun shrinkingViewportSkipsWriteBeforeNativeBottomEcho() { + assertNull( + actual = correctedComposeOffsetForViewportChange( + composeOffset = 11_869, + nativeOffset = 11_869, + contentSize = 13_643, + previousViewportSize = 1_774, + newViewportSize = 899, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun shrinkingViewportSkipsWriteAtMidList() { + assertNull( + actual = correctedComposeOffsetForViewportChange( + composeOffset = 4_000, + nativeOffset = 4_000, + contentSize = 13_643, + previousViewportSize = 1_774, + newViewportSize = 899, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun expandingViewportClampsOffsetToNewBottom() { + assertEquals( + expected = 11_869, + actual = correctedComposeOffsetForViewportChange( + composeOffset = 12_744, + nativeOffset = 12_744, + contentSize = 13_643, + previousViewportSize = 899, + newViewportSize = 1_774, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun pendingProgrammaticOwnerPreservesComposeTargetBeforeNativeEcho() { + assertNull( + actual = correctedComposeOffsetForViewportChange( + composeOffset = 4_200, + nativeOffset = 0, + contentSize = 13_643, + previousViewportSize = 1_774, + newViewportSize = 899, + programmaticOffsetPending = true, + ), + ) + } + + @Test + fun pendingProgrammaticOwnerWinsEvenBeforeContentSizeArrives() { + assertNull( + actual = correctedComposeOffsetForViewportChange( + composeOffset = 4_200, + nativeOffset = 0, + contentSize = 0, + previousViewportSize = 0, + newViewportSize = 899, + programmaticOffsetPending = true, + ), + ) + } + + @Test + fun equalRoundedViewportSkipsCorrection() { + assertNull( + actual = correctedComposeOffsetForViewportChange( + composeOffset = 400, + nativeOffset = 450, + contentSize = 500, + previousViewportSize = 100, + newViewportSize = 100, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun emptyContentResetsComposeOffsetWhenNativeOwnsState() { + assertEquals( + expected = 0, + actual = correctedComposeOffsetForViewportChange( + composeOffset = 120, + nativeOffset = 120, + contentSize = 0, + previousViewportSize = 899, + newViewportSize = 1_774, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun negativeNativeOffsetClampsToZero() { + assertEquals( + expected = 0, + actual = correctedComposeOffsetForViewportChange( + composeOffset = 120, + nativeOffset = -20, + contentSize = 500, + previousViewportSize = 899, + newViewportSize = 1_774, + programmaticOffsetPending = false, + ), + ) + } + + @Test + fun expansionWithNoScrollableRangeResetsToZero() { + assertEquals( + expected = 0, + actual = correctedComposeOffsetForViewportChange( + composeOffset = 120, + nativeOffset = 120, + contentSize = 500, + previousViewportSize = 899, + newViewportSize = 600, + programmaticOffsetPending = false, + ), + ) + } +} 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..12f1994ea --- /dev/null +++ b/compose/src/commonTest/kotlin/com/tencent/kuikly/compose/ui/platform/InputFocusTargetReducerTest.kt @@ -0,0 +1,444 @@ +/* + * 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 currentGenerationCompletionSurvivesEarlierUserFocusConfirmation() { + val reducer = InputFocusTargetReducer() + val view = View("native-tap-then-programmatic-confirmation") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + + // A native tap can report focus without a request id after Compose has already issued its + // generation-scoped focus command. This confirms the same desired target and consumes the + // pending slot, but the later programmatic completion is still current authority. + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, requestId = null), + ) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, request.generation), + ) + assertSame(view, reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun lateCurrentGenerationCompletionSurvivesRetryTimeout() { + val reducer = InputFocusTargetReducer() + val view = View("late-current-completion") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + assertTrue(reducer.onFocusRequestTimeout(view, request.generation)) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, request.generation), + ) + assertSame(view, reducer.observedView) + assertNull(reducer.reconcile()) + } + + @Test + fun currentGenerationCompletionCannotReviveAfterProgrammaticBlur() { + val reducer = InputFocusTargetReducer() + val view = View("programmatically-blurred") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + reducer.onBlurRequested(view) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale, + reducer.onNativeFocus(view, request.generation), + ) + assertNull(reducer.observedView) + } + + @Test + fun currentGenerationCompletionCannotReviveAfterUserBlurIntent() { + val reducer = InputFocusTargetReducer() + val view = View("user-blurred") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, requestId = null) + assertEquals( + InputFocusTargetReducer.NativeBlurDecision.RequestComposeClear, + reducer.onNativeBlur(view, requestId = null), + ) + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale, + reducer.onNativeFocus(view, request.generation), + ) + assertNull(reducer.observedView) + } + + @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 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() + 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 unregisterBlursDetachedObservedViewAndClearsFocusState() { + val reducer = InputFocusTargetReducer() + val view = View("detached") + + reducer.start(view) + val request = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, request.generation) + + 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()) + } + + @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) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.IgnoreStale, + reducer.onNativeFocus(view, focus.generation), + ) + assertNull(reducer.observedView) + + val refocus = assertIs>(reducer.reconcile()) + assertSame(view, refocus.view) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, refocus.generation), + ) + assertSame(view, reducer.observedView) + } + + @Test + fun softwareKeyboardShowReassertsConfirmedFocusWithoutChangingGeneration() { + val reducer = InputFocusTargetReducer() + val view = View("focused-editor-with-hidden-keyboard") + + reducer.start(view) + val initialFocus = assertIs>(reducer.reconcile()) + reducer.onNativeFocus(view, initialFocus.generation) + + assertNull(reducer.reconcile()) + val keyboardShow = + assertIs>( + reducer.reconcile(reassertCurrentFocus = true), + ) + assertSame(view, keyboardShow.view) + assertEquals(initialFocus.generation, keyboardShow.generation) + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.Confirmed, + reducer.onNativeFocus(view, keyboardShow.generation), + ) + assertSame(view, reducer.desiredView) + assertSame(view, reducer.observedView) + } + + @Test + fun softwareKeyboardShowCannotReviveObservedViewWithoutDesiredOwner() { + val reducer = InputFocusTargetReducer() + val view = View("stale-native-editor") + + assertEquals( + InputFocusTargetReducer.NativeFocusDecision.RequestComposeFocus, + reducer.onNativeFocus(view, requestId = null), + ) + + val blur = + assertIs>( + reducer.reconcile(reassertCurrentFocus = true), + ) + assertSame(view, blur.view) + assertNull(reducer.desiredView) + } + + @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/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/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.js.kt b/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.js.kt index 4fa7071d6..76d971191 100644 --- a/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.js.kt +++ b/compose/src/jsMain/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.js.kt @@ -62,6 +62,12 @@ internal actual inline fun platformScheduleOnKuiklyThread(pagerId: String) { } } +internal actual inline fun platformScheduleIdleOnKuiklyThread(pagerId: String) { + setTimeout(pagerId, 0) { + KuiklyContextScheduler.runIdleTask(pagerId) + } +} + internal actual inline fun platformNotifyKuiklyException(t: Throwable) { BridgeManager.callExceptionMethod(t.stackTraceToString()) -} \ No newline at end of file +} 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") +} 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..ecde01276 --- /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 ?: Unit, + ) +/** + * 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/compose/src/ohosArm64Main/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ohosArm64.kt b/compose/src/ohosArm64Main/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ohosArm64.kt index 3ca1f671b..b0c1976cc 100644 --- a/compose/src/ohosArm64Main/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ohosArm64.kt +++ b/compose/src/ohosArm64Main/kotlin/com/tencent/kuikly/compose/coroutines/internal/KuiklyContextScheduler.ohosArm64.kt @@ -21,6 +21,7 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.staticCFunction import kotlinx.cinterop.toKString import ohos.com_tencent_kuikly_IsCurrentOnContextThread +import ohos.com_tencent_kuikly_ScheduleContextIdleTask import ohos.com_tencent_kuikly_ScheduleContextTask internal actual fun platformInitScheduler() { @@ -38,6 +39,14 @@ internal actual inline fun platformScheduleOnKuiklyThread(pagerId: String) { }) } +@OptIn(ExperimentalForeignApi::class) +internal actual inline fun platformScheduleIdleOnKuiklyThread(pagerId: String) { + com_tencent_kuikly_ScheduleContextIdleTask(pagerId, staticCFunction { pagerIdBytes: CPointer? -> + val idStr = pagerIdBytes?.toKString() ?: return@staticCFunction + KuiklyContextScheduler.runIdleTask(idStr) + }) +} + internal actual inline fun platformNotifyKuiklyException(t: Throwable) { ExceptionTracker.notifyKuiklyException(t) -} \ No newline at end of file +} 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/build.2.1.21.gradle.kts b/core-render-android/build.2.1.21.gradle.kts index 8d30f60c3..7c3d8955a 100644 --- a/core-render-android/build.2.1.21.gradle.kts +++ b/core-render-android/build.2.1.21.gradle.kts @@ -79,4 +79,9 @@ dependencies { compileOnly(project(":core")) implementation("androidx.appcompat:appcompat:1.2.0") implementation("androidx.dynamicanimation:dynamicanimation:1.0.0") -} \ No newline at end of file + 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/KuiklyRenderView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/KuiklyRenderView.kt index 7d6302d9d..dd7298f71 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 @@ -609,6 +610,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) { @@ -683,6 +703,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 @@ -1077,4 +1108,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/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/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" } } 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..b2e97c51b 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() } /** @@ -807,6 +804,7 @@ fun View.clearViewData() { fun String?.toJSONObjectSafely(): JSONObject = JSONObject(this ?: "{}") private const val ROLE_NONE = "none" +private const val ROLE_HIDDEN = "hidden" private fun View.setAccessibilityRole(propValue: Any) { val value = when (propValue as String) { "button" -> Button::class.java.name @@ -815,6 +813,7 @@ private fun View.setAccessibilityRole(propValue: Any) { "image" -> ImageView::class.java.name "checkbox" -> CheckBox::class.java.name "none" -> ROLE_NONE + "hidden" -> ROLE_HIDDEN else -> "" } putViewData(KRCssConst.ACCESSIBILITY_ROLE, value) @@ -825,24 +824,38 @@ private fun View.setAccessibilityRole(propValue: Any) { private fun View.setTestTag(propValue: Any) { val tag = propValue as String putViewData(KRCssConst.TEST_TAG, tag) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + importantForAccessibility = resolveTestTagAccessibilityImportance( + getViewData(KRCssConst.ACCESSIBILITY_ROLE) + ) initAccessibilityDelegate() } +internal fun resolveTestTagAccessibilityImportance(role: String?): Int = + if (role == ROLE_HIDDEN) { + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } else { + View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + private fun View.setAccessibilityInfo(propValue: Any) { putViewData(KRCssConst.ACCESSIBILITY_INFO, propValue) initAccessibilityDelegate() } private fun View.setAccessibilityImportance(description: String, role: String) { - importantForAccessibility = if (role == ROLE_NONE) { + importantForAccessibility = resolveAccessibilityImportance(description, role) +} + +internal fun resolveAccessibilityImportance(description: String, role: String): Int = + if (role == ROLE_HIDDEN) { + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } else if (role == ROLE_NONE) { View.IMPORTANT_FOR_ACCESSIBILITY_NO } else if (description.isEmpty()) { View.IMPORTANT_FOR_ACCESSIBILITY_AUTO } else { View.IMPORTANT_FOR_ACCESSIBILITY_YES } -} private fun View.resetAccessibilityImportance() { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_AUTO @@ -865,37 +878,7 @@ private fun View.initAccessibilityDelegate() { accessibilityDelegate = object : AccessibilityDelegate() { override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) { super.onInitializeAccessibilityNodeInfo(host, info) - val name = getViewData(KRCssConst.ACCESSIBILITY_ROLE) - if (name != null) { - info.className = name - } - - getViewData(KRCssConst.ACCESSIBILITY_INFO)?.apply { - val flags = (this as String).split(" ") - info.isClickable = flags[0] == "1" - info.isLongClickable = flags[1] == "1" - } - - getViewData(KRCssConst.TEST_TAG)?.apply { - info.viewIdResourceName = this - } - - // Expose plain text for canvas-drawn views (e.g. KRRichTextView) only when the - // framework injected debugName (debugUIInspector). Without it, keep legacy a11y. - val a11yText = getViewData(KRCssConst.PLAIN_TEXT_FOR_A11Y) - if (!a11yText.isNullOrEmpty() && hasDebugName()) { - info.text = a11yText - } - - if (hasEventListener(KRCSSGestureListener.TYPE_CLICK)) { - info.isClickable = true - info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK) - } - - if (hasEventListener(KRCSSGestureListener.TYPE_LONG_PRESS)) { - info.isLongClickable = true - info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK) - } + applyKuiklyAccessibilityExtras(info) } override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) { @@ -910,6 +893,83 @@ private fun View.initAccessibilityDelegate() { putViewData(KRCssConst.HAD_INIT_ACCESSIBILITY_DELEGATE, true) } +/** + * Applies the Kuikly-owned accessibility extras (role/className, hidden-role + * projection, accessibilityInfo mask, testTag viewId, plain text, gesture + * actions) to a node the host has already populated. Single source: the + * attached Kuikly accessibility delegate calls exactly this after its super + * populate, and behavior tests drive the same function directly. + */ +internal fun View.applyKuiklyAccessibilityExtras(info: AccessibilityNodeInfo) { + val name = getViewData(KRCssConst.ACCESSIBILITY_ROLE) + if (name != null) { + info.className = name + } + if (name == ROLE_HIDDEN) { + configureHiddenAccessibilityNodeInfo(info) + return + } + + getViewData(KRCssConst.ACCESSIBILITY_INFO)?.apply { + val flags = (this as String).split(" ") + info.isClickable = flags[0] == "1" + info.isLongClickable = flags[1] == "1" + } + + accessibilityTestTagProjection()?.apply { + info.viewIdResourceName = this + } + + // Expose plain text for canvas-drawn views (e.g. KRRichTextView) only when the + // framework injected debugName (debugUIInspector). Without it, keep legacy a11y. + val a11yText = getViewData(KRCssConst.PLAIN_TEXT_FOR_A11Y) + if (!a11yText.isNullOrEmpty() && hasDebugName()) { + info.text = a11yText + } + + if (hasEventListener(KRCSSGestureListener.TYPE_CLICK)) { + info.isClickable = true + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK) + } + + if (hasEventListener(KRCSSGestureListener.TYPE_LONG_PRESS)) { + info.isLongClickable = true + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK) + } +} + +/** + * The production projection from the stored testTag prop to the value the + * accessibility delegate writes into AccessibilityNodeInfo.viewIdResourceName. + * Single source: applyKuiklyAccessibilityExtras applies exactly this value, + * and host tests certify it because Robolectric's ShadowAccessibilityNodeInfo + * does not implement viewIdResourceName storage; the final native-node + * readout is a device (uiautomator) gate. + */ +internal fun View.accessibilityTestTagProjection(): String? = + getViewData(KRCssConst.TEST_TAG) + +internal fun configureHiddenAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + info.isVisibleToUser = false + info.isFocusable = false + info.isFocused = false + info.isAccessibilityFocused = false + info.isClickable = false + info.isLongClickable = false + info.isEditable = false + info.isCheckable = false + info.isChecked = false + info.isSelected = false + info.text = null + info.contentDescription = null + info.removeAction(AccessibilityNodeInfo.ACTION_FOCUS) + info.removeAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) + info.removeAction(AccessibilityNodeInfo.ACTION_CLICK) + info.removeAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) + info.removeAction(AccessibilityNodeInfo.ACTION_SET_SELECTION) + info.removeAction(AccessibilityNodeInfo.ACTION_SET_TEXT) +} + internal fun View.hasDebugName(): Boolean { return getViewData(KRCssConst.DEBUG_NAME) != null } 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/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..eb5e40045 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 @@ -428,6 +429,9 @@ open class KuiklyRenderViewBaseDelegator(private val delegate: KuiklyRenderViewB renderViewExport(KRTextAreaView.VIEW_NAME, { context -> KRTextAreaView(context, delegate.softInputMode()) }) + renderViewExport(KRSelectableTextView.VIEW_NAME, { context -> + KRSelectableTextView(context) + }) renderViewExport(KRCanvasView.VIEW_NAME, { context -> KRCanvasView(context) }) @@ -550,6 +554,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 +697,4 @@ interface KuiklyRenderViewBaseDelegatorDelegate { fun debugLogEnable(): Boolean { return false } -} \ No newline at end of file +} 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..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) @@ -599,8 +655,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 +671,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 +1000,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/KRSelectableTextView.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRSelectableTextView.kt new file mode 100644 index 000000000..1b1dcd53d --- /dev/null +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/component/KRSelectableTextView.kt @@ -0,0 +1,196 @@ +/* + * 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.content.Context +import android.graphics.Color +import android.text.Spannable +import android.text.SpannableString +import android.util.TypedValue +import android.view.Gravity +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.TextView +import com.tencent.kuikly.core.render.android.const.KRCssConst +import com.tencent.kuikly.core.render.android.css.ktx.spToPxI +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.text.FontWeightSpan +import com.tencent.kuikly.core.render.android.expand.component.text.HRLineHeightSpan +import com.tencent.kuikly.core.render.android.export.IKuiklyRenderViewExport + +/** + * System-selectable plain text: a read-only [TextView] with + * [setTextIsSelectable] enabled so the platform ActionMode appears anchored + * to the selection. Baseline guarantee: Select all / Copy. Additional items + * (e.g. Translate or other PROCESS_TEXT targets) appear only when the OS + * version, locale and installed services provide them. Never editable, + * never shows an IME; text changes only through the "text" prop. + */ +class KRSelectableTextView(context: Context) : TextView(context), IKuiklyRenderViewExport { + + private var rawText: String = "" + private var lineHeightPx: Int? = null + private var useDpFontSizeDim = false + private var rawFontSize: Float? = null + private var rawLineHeight: Float? = null + + init { + setTextIsSelectable(true) + gravity = Gravity.LEFT or Gravity.TOP + includeFontPadding = false + setTextColor(Color.BLACK) + background = null + setPadding(0, 0, 0, 0) + } + + // Selection state must never leak across cells/pages. + override val reusable: Boolean + get() = false + + override fun setProp(propKey: String, propValue: Any): Boolean { + return when (propKey) { + PROP_TEXT -> { + rawText = propValue as? String ?: "" + applyText() + true + } + PROP_FONT_SIZE -> { + rawFontSize = (propValue as Number).toFloat() + applyFontSize() + true + } + PROP_FONT_WEIGHT -> { + FontWeightSpan(propValue as String).updateDrawState(paint) + applyText() + true + } + PROP_COLOR -> { + setTextColor((propValue as String).toColor()) + true + } + PROP_LINE_HEIGHT -> { + rawLineHeight = (propValue as Number).toFloat() + applyLineHeight() + true + } + PROP_TEXT_ALIGN -> { + applyTextAlign(propValue as String) + true + } + PROP_USE_DP_FONT_SIZE_DIM -> { + useDpFontSizeDim = (propValue as Int) == 1 + applyFontSize() + applyLineHeight() + true + } + // View capability: this surface's clickable/long-clickable a11y + // truth comes from the system TextView selection semantics. The + // compose semantics bridge derives its boolean mask from compose + // click semantics (absent here) and would report the view as not + // long-clickable, breaking a11y ACTION_LONG_CLICK and automation + // readouts. Decline only this mask; every other a11y prop (role, + // testTag, plain text, state description) still applies normally. + KRCssConst.ACCESSIBILITY_INFO -> true + else -> super.setProp(propKey, propValue) + } + } + + /** + * Final-layer accessibility truth: whatever delegates or masked props ran + * upstream, the exposed node info derives clickable/long-clickable from + * the real view flags and advertises the system selection actions. + */ + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + info.isClickable = isClickable + info.isLongClickable = isLongClickable + if (isLongClickable) { + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK) + } + if (isTextSelectable) { + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_SELECTION) + } + } + + private fun applyFontSize() { + val fontSize = rawFontSize ?: return + setTextSize( + TypedValue.COMPLEX_UNIT_PX, + if (useDpFontSizeDim) { + kuiklyRenderContext.toPxF(fontSize) + } else { + kuiklyRenderContext.spToPxI(fontSize).toFloat() + } + ) + } + + private fun applyLineHeight() { + val lineHeight = rawLineHeight ?: return + lineHeightPx = if (useDpFontSizeDim) { + kuiklyRenderContext.toPxI(lineHeight) + } else { + kuiklyRenderContext.spToPxI(lineHeight) + } + applyText() + } + + private fun applyTextAlign(align: String) { + val horizontal = when (align) { + "center" -> Gravity.CENTER_HORIZONTAL + "right" -> Gravity.RIGHT + else -> Gravity.LEFT + } + gravity = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK.inv()) or horizontal + } + + private fun applyText() { + val content = SpannableString(rawText) + lineHeightPx?.takeIf { it > 0 }?.also { height -> + if (content.isNotEmpty()) { + content.setSpan( + HRLineHeightSpan(height), + 0, + content.length, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + } + } + setText(content, BufferType.SPANNABLE) + } + + companion object { + const val VIEW_NAME = "KRSelectableTextView" + + internal const val PROP_TEXT = "text" + internal const val PROP_FONT_SIZE = "fontSize" + internal const val PROP_FONT_WEIGHT = "fontWeight" + internal const val PROP_COLOR = "color" + internal const val PROP_LINE_HEIGHT = "lineHeight" + internal const val PROP_TEXT_ALIGN = "textAlign" + internal const val PROP_USE_DP_FONT_SIZE_DIM = "useDpFontSizeDim" + + internal val HANDLED_PROPS = setOf( + PROP_TEXT, + PROP_FONT_SIZE, + PROP_FONT_WEIGHT, + PROP_COLOR, + PROP_LINE_HEIGHT, + PROP_TEXT_ALIGN, + PROP_USE_DP_FONT_SIZE_DIM + ) + } +} 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..461bf8bfd 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 @@ -61,11 +61,16 @@ import com.tencent.kuikly.core.render.android.expand.component.text.FontWeightSp import com.tencent.kuikly.core.render.android.expand.component.text.HRLineHeightSpan import com.tencent.kuikly.core.render.android.expand.component.text.KRRichTextBuilder import com.tencent.kuikly.core.render.android.expand.module.KRKeyboardModule +import com.tencent.kuikly.core.render.android.expand.module.KeyboardHeightDispatchGate import com.tencent.kuikly.core.render.android.expand.module.KeyboardStatusListener 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单行输入组件 */ @@ -142,8 +147,9 @@ 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 private var maxTextLength: Int? = null @@ -267,8 +273,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) @@ -316,9 +323,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) @@ -617,10 +622,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() @@ -651,14 +668,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 @@ -686,11 +719,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 @@ -794,9 +829,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 @@ -821,13 +861,13 @@ open class KRTextFieldView(context: Context, private val softInputMode: Int?) : @Suppress("UNCHECKED_CAST") keyboardHeightChangeCallback = propValue as KuiklyRenderCallback + val keyboardHeightDispatchGate = KeyboardHeightDispatchGate() // 键盘状态监听 keyboardStatusListener = object : KeyboardStatusListener { override fun onHeightChanged(keyboardHeight: Int) { - if (keyboardHeight == currentKeyboardHeight) { + if (!keyboardHeightDispatchGate.accept(keyboardHeight)) { return } - currentKeyboardHeight = keyboardHeight keyboardHeightChangeCallback?.invoke( mapOf( KRViewConst.HEIGHT to kuiklyRenderContext.toDpF(keyboardHeight.toFloat()), @@ -851,16 +891,15 @@ 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 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 { @@ -980,11 +1019,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 } }) } @@ -1020,8 +1064,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) } } } @@ -1083,6 +1126,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" @@ -1098,6 +1142,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 NO_COMPOSITION = -1 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-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 e29629fea..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 @@ -502,6 +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 -> null METHOD_CONTENT_OFFSET -> setContentOffset(params) METHOD_CONTENT_INSET_WHEN_END_DRAG -> contentInsetWhenEndDrag(params) METHOD_CONTENT_INSET -> contentInset(params) @@ -703,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) @@ -1496,6 +1499,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" @@ -2021,4 +2025,4 @@ class KRRecyclerView : RecyclerView, IKuiklyRenderViewExport, NestedScrollingChi } return super.canScrollVertically(direction) } -} \ No newline at end of file +} 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/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..630141730 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 @@ -27,6 +27,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 @@ -50,11 +51,30 @@ 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.ceil -import kotlin.math.floor 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 = 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). 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 +internal const val INLINE_BOX_LAYOUT_JOINER = '\u2060' +private const val INLINE_BOX_LAYOUT_EDGE = '\uFFFC' + +internal fun CharSequence.isInlineBoxGroupAtLineStart(): Boolean = + isEmpty() || this[lastIndex] == '\n' + /** * 富文本构造器 */ @@ -82,30 +102,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 - ) - } - spannedBuilder.append(buildSpannedString { - // 记录 Span 对应的文字范围 - spanTextRanges.add( - SpanTextRange( - index, - spannedBuilder.length, - spannedBuilder.length + spanProps.text.length - ) - ) - inSpans(spans) { - append(spanProps.text) - } - }) - + 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, + ) } } if (textProps.richTextHeadIndent != 0) { @@ -127,6 +139,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) } @@ -182,8 +197,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)) @@ -191,12 +204,32 @@ 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)) + if (spanProps.backgroundColor != Color.TRANSPARENT && + !spanProps.slockInlineCode && + spanProps.inlineBoxStyle == null + ) { + textSpans.add(BackgroundColorSpan(spanProps.backgroundColor)) + } 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( + createKRCustomUnderlineSpan( + color = spanProps.textDecorationColor, + thickness = spanProps.textDecorationThickness, + offset = spanProps.textDecorationOffset + ) + ) } else { textSpans.add(UnderlineSpan()) } @@ -204,6 +237,15 @@ class KRRichTextBuilder(private val kuiklyContext: IKuiklyRenderContext?) { if (spanProps.backgroundImage.isNotEmpty()) { textSpans.add(LinearGradientForegroundSpan(spanProps.backgroundImage, layoutSizeGetter)) } + if (spanProps.slockInlineCode) { + textSpans.add(KRSlockInlineCodeSpan()) + } + if (spanProps.slockInlineCodeTrailingMargin) { + textSpans.add(KRSlockInlineCodeTrailingMarginSpan()) + } + spanProps.inlineBoxStyle?.let { style -> + textSpans.add(KRInlineBoxSpan(style)) + } spanProps.textShadow?.let { if (!it.isEmpty()) { @@ -228,8 +270,150 @@ 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) { + 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, + // buildList adds a List receiver here. Using its isEmpty()/lastIndex + // against the SpannableStringBuilder crashes when the group already has a + // child but the rich-text builder is still empty (charAt(0) on length 0). + isStart = this@appendInlineBoxGroup.isInlineBoxGroupAtLineStart(), + ) + 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(), + 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)) + } + } + } +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 @@ -250,8 +434,15 @@ 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 + val slockInlineCode: Boolean + val slockInlineCodeTrailingMargin: Boolean + val inlineBoxStyle: KRInlineBoxSpanStyle? var textShadow: BoxShadow? = null var useDpFontSizeDim = false @@ -292,12 +483,36 @@ 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 { 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) + slockInlineCodeTrailingMargin = spanValue.optInt(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, 0) == 1 || + spanValue.optBoolean(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, false) + 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 @@ -325,12 +540,435 @@ 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 + +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 + } +} + +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 +} + +/** + * Uses a marker [CharacterStyle] whenever the caller customizes underline color, thickness, and/or + * offset. + * [KRRichTextViewDrawer] overlays those marked ranges in an isolated layer and punches gaps from + * the resolved glyph ink geometry, matching CSS `text-decoration-skip-ink: auto` while preserving + * existing text/background drawing. The marker remains breakable; the previous all-purpose + * [ReplacementSpan] made the complete decorated range one atomic layout run and could push a long + * URL outside its available width. + * + * Android does not expose a public underline-offset field on [TextPaint], so the drawer applies the + * requested offset directly without turning the decorated range into an atomic replacement. + */ +internal fun createKRCustomUnderlineSpan( + color: Int?, + thickness: Float?, + offset: Float?, +): Any = KRSkipInkCustomUnderlineSpan(color = color, thickness = thickness, offset = offset) + +internal class KRSkipInkCustomUnderlineSpan( + internal val color: Int?, + internal val thickness: Float?, + internal val offset: Float?, +) : CharacterStyle(), UpdateAppearance { + + override fun updateDrawState(textPaint: TextPaint) { + // The view drawer owns this decoration. Keep Android TextLine's post-glyph underline paths + // disabled so there is one SSOT and glyph ink can cover the stroke. + textPaint.isUnderlineText = false + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + textPaint.underlineColor = 0 + textPaint.underlineThickness = 0f + } + } +} + +private fun SpannableStringBuilder.applyInlineBoxAtomicTextSpan( + start: Int, + end: Int, + style: KRInlineBoxSpanStyle, +) { + if (start < end) { + setSpan( + KRInlineBoxAtomicTextSpan(style), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } +} + +internal class KRInlineBoxAtomicTextSpan( + private val style: KRInlineBoxSpanStyle, +) : ReplacementSpan() { + internal var measuredWidth: Int = 0 + private set + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (text == null || start >= end) { + measuredWidth = 0 + 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 + measuredWidth = + ceil((paint.measureText(text, start, end) + edgeStart + edgeEnd).toDouble()).toInt() + return measuredWidth + } + + 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) + } + } +} + +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 + while (index < end) { + if (this[index].isSlockInlineCodeAtomBoundaryWhitespace()) { + index++ + continue + } + val rangeStart = index + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val textStart = index + while (index < end && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + 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 && + this[index].isSlockInlineCodeBreakSeparator() + ) { + val separatorStart = index + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val nextTextStart = index + while (index < end && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + index++ + } + if (index <= nextTextStart) { + index = separatorStart + break + } + textLength = 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( + KRSlockInlineCodeAtomicTextSpan(padStart, padEnd), + rangeStart, + index, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + firstAtom = false + } + } +} + +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].isSlockInlineCodeAtomBoundaryWhitespace()) { + index++ + continue + } + while (index < end && this[index].isSlockInlineCodeBreakSeparator()) { + index++ + } + val textStart = index + while (index < end && + !this[index].isSlockInlineCodeAtomBoundaryWhitespace() && + !this[index].isSlockInlineCodeBreakSeparator() + ) { + index++ + } + if (index > textStart) return true + } + return false +} + +private class KRSlockInlineCodeAtomicTextSpan( + private val padStart: 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( + 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 = if (seamless) 0f else max(1f, paint.strokeWidth * 2f) + ceil((textWidth + strokePadding + startPadding(paint) + endPadding(paint)).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 + 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 字重 @@ -338,10 +976,17 @@ data class SpanTextRange(val index: Int, val start: Int, val end: Int) { */ 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 (strokeWidth != 0f) { + val nativeTypefaceSatisfiesWeight = + requestedWeight == FONT_WEIGHT_BOLD.toInt() && tp.typeface?.isBold == true + if (fakeBold && !nativeTypefaceSatisfiesWeight) { + tp.isFakeBoldText = true + } + if (strokeWidth != 0f && !nativeTypefaceSatisfiesWeight) { tp.style = Paint.Style.FILL_AND_STROKE tp.strokeWidth = strokeWidth * tp.textSize } @@ -374,6 +1019,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 } } @@ -442,6 +1092,12 @@ class FontFamilySpan(fontFamily: String, typeFaceLoader: TypeFaceLoader?) : Type class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { + // 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, @@ -450,11 +1106,12 @@ class HRLineHeightSpan(internal val height: Int) : LineHeightSpan { lineHeight: Int, 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() - fm.ascent = fm.top - fm.descent = fm.bottom + val additional: Int = height - (fm.descent - fm.ascent) + val topExtra = additional / 2 + fm.ascent -= topExtra + fm.descent += additional - topExtra + fm.top = fm.ascent + fm.bottom = fm.descent } } @@ -599,4 +1256,4 @@ class KRPlaceholderSpan(private val spanProps: PlaceholderSpanProps): Replacemen return spanProps.height } -} \ No newline at end of file +} 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 c07de8ccc..f87a61118 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,11 +16,16 @@ 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.PorterDuff +import android.graphics.PorterDuffXfermode import android.graphics.RectF import android.os.Build import android.text.Layout import android.text.Spanned +import android.text.TextPaint +import android.text.style.CharacterStyle import android.text.style.ReplacementSpan import com.tencent.kuikly.core.render.android.expand.component.KRTextProps import com.tencent.kuikly.core.render.android.expand.component.SelectionEdge @@ -28,8 +33,24 @@ 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 private const val INVALID_OFFSET = -1 +// 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 +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 /** * 富文本绘制器,封装 [Layout],用于富文本视图的测量与绘制。 @@ -44,6 +65,38 @@ 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().apply { + style = Paint.Style.FILL + color = SLOCK_INLINE_CODE_BORDER_COLOR + isAntiAlias = false + } + private val slockInlineCodeRect = 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 inlineBoxSelectionPath = Path() + private val inlineBoxLineClipPath = Path() + private val inlineBoxSelectionBounds = RectF() + private val customUnderlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } + private val customUnderlineClearPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL_AND_STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) + } + private val customUnderlineSelectionPath = Path() + private val customUnderlineGlyphPath = Path() + private val customUnderlineGlyphBounds = RectF() private val wordIterator by lazy(LazyThreadSafetyMode.NONE) { WordIterator(textLayout.text, 0, textLayout.text.length, Locale.getDefault()) @@ -67,7 +120,395 @@ class KRRichTextViewDrawer(val textLayout: Layout) { * 将文本内容绘制到 [canvas],对接到 [Layout.draw]。 */ fun draw(canvas: Canvas) { + drawInlineBoxChrome(canvas, drawFill = true, drawBorder = false) + drawSlockInlineCodeChrome(canvas, drawFill = true, drawBorder = false) textLayout.draw(canvas) + drawCustomUnderlines(canvas) + drawSlockInlineCodeChrome(canvas, drawFill = false, drawBorder = true) + drawInlineBoxChrome(canvas, drawFill = false, drawBorder = true) + } + + private fun drawCustomUnderlines(canvas: Canvas) { + val spanned = textLayout.text as? Spanned ?: return + val spans = spanned.getSpans(0, spanned.length, KRSkipInkCustomUnderlineSpan::class.java) + if (spans.isEmpty()) return + + spans.forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + if (start < 0 || end <= start) return@forEach + + val layer = canvas.saveLayer( + 0f, + 0f, + textLayout.width.toFloat(), + textLayout.height.toFloat(), + null, + ) + val startLine = textLayout.getLineForOffset(start) + val endLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start)) + for (line in startLine..endLine) { + val segmentStart = max(start, textLayout.getLineStart(line)) + val segmentEnd = min(end, textLayout.getLineVisibleEnd(line)) + if (segmentEnd <= segmentStart) continue + + customUnderlineSelectionPath.reset() + textLayout.getSelectionPath(segmentStart, segmentEnd, customUnderlineSelectionPath) + if (customUnderlineSelectionPath.isEmpty) continue + val clipped = canvas.save() + val lineLeft = min(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + val lineRight = max(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + canvas.clipRect( + lineLeft, + textLayout.getLineTop(line).toFloat(), + lineRight, + textLayout.getLineBottom(line).toFloat(), + ) + canvas.clipPath(customUnderlineSelectionPath) + val resolvedPaint = resolveTextPaint(spanned, segmentStart, span) + customUnderlinePaint.color = span.color ?: resolvedPaint.color + customUnderlinePaint.strokeWidth = + span.thickness ?: defaultUnderlineThickness(resolvedPaint) + val underlineY = + textLayout.getLineBaseline(line).toFloat() + + (span.offset ?: underlinePosition(resolvedPaint)) + canvas.drawLine( + 0f, + underlineY, + textLayout.width.toFloat(), + underlineY, + customUnderlinePaint, + ) + clearGlyphInkGaps( + canvas = canvas, + spanned = spanned, + start = segmentStart, + end = segmentEnd, + line = line, + underlineY = underlineY, + thickness = customUnderlinePaint.strokeWidth, + marker = span, + ) + canvas.restoreToCount(clipped) + } + canvas.restoreToCount(layer) + } + } + + private fun resolveTextPaint( + spanned: Spanned, + offset: Int, + marker: KRSkipInkCustomUnderlineSpan, + ): TextPaint = TextPaint(textLayout.paint).also { resolved -> + val queryEnd = (offset + 1).coerceAtMost(spanned.length) + spanned.getSpans(offset, queryEnd, CharacterStyle::class.java).forEach { style -> + if (style !== marker) style.updateDrawState(resolved) + } + } + + private fun clearGlyphInkGaps( + canvas: Canvas, + spanned: Spanned, + start: Int, + end: Int, + line: Int, + underlineY: Float, + thickness: Float, + marker: KRSkipInkCustomUnderlineSpan, + ) { + var offset = start + val halfThickness = thickness / 2f + // Clear the actual glyph outline plus a restrained halo. A full glyph-bounds rectangle + // makes descenders erase the underline across their complete advance; no halo makes the + // skip nearly invisible at production scale. One underline thickness keeps the gap + // legible while remaining shaped by the glyph rather than by its bounding box. + customUnderlineClearPaint.strokeWidth = max(1f, thickness) + while (offset < end) { + val codePoint = Character.codePointAt(spanned, offset) + val next = (offset + Character.charCount(codePoint)).coerceAtMost(end) + val glyph = spanned.subSequence(offset, next).toString() + val resolvedPaint = resolveTextPaint(spanned, offset, marker) + val advance = resolvedPaint.measureText(glyph) + val caretX = textLayout.getPrimaryHorizontal(offset) + val glyphX = if (textLayout.isRtlCharAt(offset)) caretX - advance else caretX + customUnderlineGlyphPath.reset() + resolvedPaint.getTextPath( + glyph, + 0, + glyph.length, + glyphX, + textLayout.getLineBaseline(line).toFloat(), + customUnderlineGlyphPath, + ) + if (!customUnderlineGlyphPath.isEmpty) { + customUnderlineGlyphPath.computeBounds(customUnderlineGlyphBounds, true) + val bandTop = underlineY - halfThickness + val bandBottom = underlineY + halfThickness + if ( + customUnderlineGlyphBounds.bottom >= bandTop && + customUnderlineGlyphBounds.top <= bandBottom + ) { + canvas.drawPath(customUnderlineGlyphPath, customUnderlineClearPaint) + } + } + offset = next + } + } + + private fun defaultUnderlineThickness(paint: Paint): Float = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + paint.underlineThickness.takeIf { it > 0f } + ?: (paint.textSize / 18f).coerceAtLeast(1f) + } else { + (paint.textSize / 18f).coerceAtLeast(1f) + } + + private fun underlinePosition(paint: Paint): Float = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + paint.underlinePosition.takeIf { it > 0f } + ?: (paint.textSize / 9f).coerceAtLeast(1f) + } else { + (paint.textSize / 9f).coerceAtLeast(1f) + } + + 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 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) { + 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 atomicBounds = + atomicSpan?.let { atomicInlineBoxBounds(start, end, line, it) } + val clipToSelectionPath = atomicBounds == null + 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 segmentBounds = inlineBoxSelectionBounds(segmentStart, segmentEnd, line) + ?: continue + segmentLeft = segmentBounds.left + segmentRight = segmentBounds.right + } + 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) + val saveCount = + if (clipToSelectionPath) { + canvas.save().also { canvas.clipPath(inlineBoxSelectionPath) } + } else { + null + } + 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 + ) + } + if (saveCount != null) canvas.restoreToCount(saveCount) + } + } + } + + private fun atomicInlineBoxBounds( + start: Int, + end: Int, + line: Int, + atomicSpan: KRInlineBoxAtomicTextSpan, + ): RectF? { + val measuredWidth = atomicSpan.measuredWidth.toFloat() + if (measuredWidth <= 0f) return null + val bounds = inlineBoxSelectionBounds(start, end, line) ?: return null + if (textLayout.getParagraphDirection(line) >= 0) { + bounds.right = min(textLayout.width.toFloat(), bounds.left + measuredWidth) + } else { + bounds.left = max(0f, bounds.right - measuredWidth) + } + return bounds + } + + private fun inlineBoxSelectionBounds(start: Int, end: Int, line: Int): RectF? { + inlineBoxSelectionPath.reset() + textLayout.getSelectionPath(start, end, inlineBoxSelectionPath) + inlineBoxLineClipPath.reset() + val lineLeft = min(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + val lineRight = max(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + inlineBoxLineClipPath.addRect( + lineLeft, + textLayout.getLineTop(line).toFloat(), + lineRight, + 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 + 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) + 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 + + 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 = + 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 + } + 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() + 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) + if (drawFill) { + canvas.drawRect(slockInlineCodeRect, slockInlineCodeFillPaint) + } + if (drawBorder) { + canvas.drawSlockInlineCodeBorder(left, top, right, bottom) + } + } + } + } + + // 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 = slockChipBorderWidthPx + 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 { + val lineStart = getLineStart(line) + val ellipsisCount = getEllipsisCount(line) + if (ellipsisCount > 0) { + return (lineStart + getEllipsisStart(line)).coerceAtLeast(lineStart) + } + return getLineVisibleEnd(line) } internal fun setSelectionByCoordinate( @@ -194,7 +635,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 } @@ -202,7 +643,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 } @@ -211,7 +652,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 } @@ -462,4 +903,37 @@ class KRRichTextViewDrawer(val textLayout: Layout) { } -} \ No newline at end of file +} + +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/main/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModule.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModule.kt index 09efbe6d5..81fe15a62 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModule.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModule.kt @@ -19,6 +19,7 @@ import com.tencent.kuikly.core.render.android.css.ktx.toJSONObjectSafely import com.tencent.kuikly.core.render.android.export.KuiklyRenderBaseModule import com.tencent.kuikly.core.render.android.export.KuiklyRenderCallback import java.io.File +import java.util.concurrent.Executors /** * 文件读写 Module,提供 App Caches 目录的文件写入能力。 @@ -61,6 +62,7 @@ class KRFileModule : KuiklyRenderBaseModule() { val json = params.toJSONObjectSafely() val filename = json.optString(PARAM_FILENAME) val content = json.optString(PARAM_CONTENT) + val operationId = json.optString(PARAM_OPERATION_ID).orEmpty() if (filename.isNullOrEmpty() || content.isNullOrEmpty()) { callback?.invoke(mapOf("error" to "missing filename or content")) @@ -73,25 +75,35 @@ class KRFileModule : KuiklyRenderBaseModule() { return } - Thread { + FILE_EXECUTOR.execute { + val file = File(dir, filename) + if (operationId.isNotEmpty() && COMPLETED_OPERATION_IDS.contains(operationId)) { + callback?.invoke(mapOf("path" to file.absolutePath)) + return@execute + } try { - val file = File(dir, filename) // 追加写,末尾加换行,适合 JSONL 格式 file.appendText(content + "\n", Charsets.UTF_8) + if (operationId.isNotEmpty()) { + COMPLETED_OPERATION_IDS.add(operationId) + } callback?.invoke(mapOf("path" to file.absolutePath)) } catch (e: Exception) { callback?.invoke(mapOf("error" to (e.message ?: "unknown error"))) } - }.start() + } } private fun writeFile(params: String?, callback: KuiklyRenderCallback?) { val json = params.toJSONObjectSafely() val filename = json.optString(PARAM_FILENAME) val content = json.optString(PARAM_CONTENT) + val operationId = json.optString(PARAM_OPERATION_ID).orEmpty() - if (filename.isNullOrEmpty() || content.isNullOrEmpty()) { - callback?.invoke(mapOf("error" to "missing filename or content")) + // Empty content is a valid overwrite operation: profiler start/reset uses it to truncate + // the previous session's report before any new frame is recorded. + if (filename.isNullOrEmpty()) { + callback?.invoke(mapOf("error" to "missing filename")) return } @@ -101,16 +113,24 @@ class KRFileModule : KuiklyRenderBaseModule() { return } - // 在后台线程执行文件写入,避免阻塞 UI - Thread { + // A process-wide FIFO keeps writes ordered even when a profiler operation is retried + // through another Pager after the first Pager lost its native callback. + FILE_EXECUTOR.execute { + val file = File(dir, filename) + if (operationId.isNotEmpty() && COMPLETED_OPERATION_IDS.contains(operationId)) { + callback?.invoke(mapOf("path" to file.absolutePath)) + return@execute + } try { - val file = File(dir, filename) file.writeText(content, Charsets.UTF_8) + if (operationId.isNotEmpty()) { + COMPLETED_OPERATION_IDS.add(operationId) + } callback?.invoke(mapOf("path" to file.absolutePath)) } catch (e: Exception) { callback?.invoke(mapOf("error" to (e.message ?: "unknown error"))) } - }.start() + } } companion object { @@ -120,5 +140,12 @@ class KRFileModule : KuiklyRenderBaseModule() { private const val METHOD_GET_FILES_DIR = "getFilesDir" private const val PARAM_FILENAME = "filename" private const val PARAM_CONTENT = "content" + private const val PARAM_OPERATION_ID = "operationId" + + /** All accesses to [COMPLETED_OPERATION_IDS] run on this process-wide FIFO executor. */ + private val FILE_EXECUTOR = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "KuiklyProfilerFile").apply { isDaemon = true } + } + private val COMPLETED_OPERATION_IDS = HashSet() } } diff --git a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRKeyboardModule.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRKeyboardModule.kt index ecfb6902e..47581b892 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRKeyboardModule.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/expand/module/KRKeyboardModule.kt @@ -17,6 +17,7 @@ package com.tencent.kuikly.core.render.android.expand.module import android.app.Activity import android.content.Context +import android.content.pm.ApplicationInfo import android.graphics.Rect import android.os.Build import android.view.Gravity @@ -27,10 +28,11 @@ import android.view.WindowInsets import android.view.WindowManager import android.widget.FrameLayout import android.widget.PopupWindow -import java.util.concurrent.CopyOnWriteArrayList import com.tencent.kuikly.core.render.android.adapter.KuiklyRenderLog import com.tencent.kuikly.core.render.android.css.ktx.isAfterAndroid11 +import com.tencent.kuikly.core.render.android.css.ktx.isMainThread import com.tencent.kuikly.core.render.android.export.KuiklyRenderBaseModule +import java.util.concurrent.CopyOnWriteArrayList /** * 用于获取/监听键盘的相关状态 @@ -77,6 +79,109 @@ class KRKeyboardModule : KuiklyRenderBaseModule() { } +/** + * Keeps the latest window-level IME height independently from any individual text editor. + * + * Text editors can be replaced while the keyboard stays visible (for example, after a send clears + * and retires the active editor session). A replacement listener must first receive the current + * height; otherwise its local initial value is also zero and the next visible -> hidden transition + * is incorrectly suppressed as a duplicate zero. + * + * Registration, removal, replay and transition callbacks are serialized under [dispatchLock] so a + * replay cannot arrive after a newer transition. All registry entry points are main-thread-only; + * [verifyKeyboardHeightRegistryMainThread] makes that ordering premise executable instead of relying + * on callback comments. + */ +internal class KeyboardHeightListenerRegistry( + private val failFastOnThreadViolation: Boolean, + private val isOnMainThread: () -> Boolean = ::isMainThread, + private val reportThreadViolation: (String) -> Unit = { message -> + KuiklyRenderLog.e(KRKeyboardModule.MODULE_NAME, message) + } +) { + private val listeners = CopyOnWriteArrayList() + private val dispatchLock = Any() + private var currentHeight = 0 + + fun addListener(listener: KeyboardStatusListener) { + verifyMainThread("addListener") + synchronized(dispatchLock) { + listeners.add(listener) + listener.onHeightChanged(currentHeight) + } + } + + fun removeListener(listener: KeyboardStatusListener) { + verifyMainThread("removeListener") + synchronized(dispatchLock) { + listeners.remove(listener) + } + } + + fun dispatchHeight(height: Int) { + verifyMainThread("dispatchHeight") + synchronized(dispatchLock) { + if (height == currentHeight) return + currentHeight = height + for (listener in listeners) { + listener.onHeightChanged(height) + } + } + } + + fun clear() { + verifyMainThread("clear") + synchronized(dispatchLock) { + listeners.clear() + currentHeight = 0 + } + } + + private fun verifyMainThread(operation: String) { + verifyKeyboardHeightRegistryMainThread( + operation = operation, + isOnMainThread = isOnMainThread(), + failFast = failFastOnThreadViolation, + reportViolation = reportThreadViolation + ) + } +} + +internal fun verifyKeyboardHeightRegistryMainThread( + operation: String, + isOnMainThread: Boolean, + failFast: Boolean, + reportViolation: (String) -> Unit +) { + if (isOnMainThread) return + + val message = + "KeyboardHeightListenerRegistry.$operation must run on the Android main thread; " + + "actual=${Thread.currentThread().name}" + if (failFast) { + throw IllegalStateException(message) + } + reportViolation(message) +} + +/** + * Deduplicates keyboard heights within one editor listener lifetime. + * + * The first value is always accepted, including zero. A replacement editor can be registered after + * the watcher has already observed a visible -> hidden transition while no listener was attached; + * in that ordering the registry replays zero, and that replay must still retire the page's stale + * non-zero inset. + */ +internal class KeyboardHeightDispatchGate { + private var lastHeight: Int? = null + + fun accept(height: Int): Boolean { + if (lastHeight == height) return false + lastHeight = height + return true + } +} + /** * Android 11 以下键盘状态监听,通过往 Activity 添加一个 popupView 监听键盘状态变化 */ @@ -95,7 +200,10 @@ class KeyboardStatusWatcher(private val activity: Activity) : PopupWindow(activi private var lastVisibleHeight = -1 private var lastVisibleBottom = -1 private var lastScreenHeight = -1 - private val listeners = CopyOnWriteArrayList() + private val listenerRegistry = + KeyboardHeightListenerRegistry( + failFastOnThreadViolation = activity.isDebuggableApplication() + ) init { contentView = popupView @@ -175,17 +283,15 @@ class KeyboardStatusWatcher(private val activity: Activity) : PopupWindow(activi } fun addListener(listener: KeyboardStatusListener) { - listeners.add(listener) + listenerRegistry.addListener(listener) } fun removeListener(listener: KeyboardStatusListener) { - listeners.remove(listener) + listenerRegistry.removeListener(listener) } private fun notifyKeyboardHeightChanged(height: Int) { - for (listener in listeners) { - listener.onHeightChanged(height) - } + listenerRegistry.dispatchHeight(height) } fun destroy() { @@ -194,7 +300,7 @@ class KeyboardStatusWatcher(private val activity: Activity) : PopupWindow(activi dismiss() } popupView.viewTreeObserver.removeOnGlobalLayoutListener(this) - listeners.clear() + listenerRegistry.clear() } } @@ -204,8 +310,10 @@ class KeyboardStatusWatcher(private val activity: Activity) : PopupWindow(activi */ class Android11PlusKeyboardWatcher(private val activity: Activity) : ViewTreeObserver.OnGlobalLayoutListener { - private var lastKeyboardHeight = 0 - private val listeners = CopyOnWriteArrayList() + private val listenerRegistry = + KeyboardHeightListenerRegistry( + failFastOnThreadViolation = activity.isDebuggableApplication() + ) init { val parentView = activity.findViewById(android.R.id.content) @@ -226,30 +334,21 @@ class Android11PlusKeyboardWatcher(private val activity: Activity) : ViewTreeObs 0 } - if (newKeyboardHeight != lastKeyboardHeight) { - notifyKeyboardHeightChanged(newKeyboardHeight) - lastKeyboardHeight = newKeyboardHeight - } + listenerRegistry.dispatchHeight(newKeyboardHeight) } fun addListener(listener: KeyboardStatusListener) { - listeners.add(listener) + listenerRegistry.addListener(listener) } fun removeListener(listener: KeyboardStatusListener) { - listeners.remove(listener) - } - - private fun notifyKeyboardHeightChanged(height: Int) { - for (listener in listeners) { - listener.onHeightChanged(height) - } + listenerRegistry.removeListener(listener) } fun destroy() { val parentView = activity.findViewById(android.R.id.content) parentView?.viewTreeObserver?.removeOnGlobalLayoutListener(this) - listeners.clear() + listenerRegistry.clear() } } @@ -258,4 +357,7 @@ class Android11PlusKeyboardWatcher(private val activity: Activity) : ViewTreeObs */ interface KeyboardStatusListener { fun onHeightChanged(height: Int) -} \ No newline at end of file +} + +private fun Context.isDebuggableApplication(): Boolean = + applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 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-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreContextScheduler.kt b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreContextScheduler.kt index eae4d6aad..792ceb1cd 100644 --- a/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreContextScheduler.kt +++ b/core-render-android/src/main/java/com/tencent/kuikly/core/render/android/scheduler/KuiklyRenderCoreContextScheduler.kt @@ -19,6 +19,7 @@ import android.os.ConditionVariable import android.os.Handler import android.os.HandlerThread import android.os.Looper +import android.os.MessageQueue import android.os.Process import com.tencent.kuikly.core.nvi.NativeBridge import com.tencent.kuikly.core.render.android.adapter.KuiklyRenderAdapterManager @@ -38,11 +39,54 @@ object KuiklyRenderCoreContextScheduler : IKuiklyRenderCoreScheduler { KRHandlerThread(THREAD_NAME, Process.THREAD_PRIORITY_FOREGROUND, stackSize).apply { start() }.looper }) } + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } override fun scheduleTask(delayMs: Long, task: Runnable) { handler.postDelayed(task, delayMs) } + /** + * Runs one bounded task after this context looper has drained normal work. + * + * The callback temporarily uses background thread priority so speculative + * work yields CPU to interactive threads. A task already executing is not + * forcibly interrupted; callers must keep each callback bounded. + */ + fun scheduleIdleTask(task: Runnable) { + scheduleAfterContextIdle { + mainHandler.post { + Looper.myQueue().addIdleHandler( + MessageQueue.IdleHandler { + // Foreground context work may have arrived while the + // main queue was draining. Re-admit on context idle. + scheduleAfterContextIdle { + val threadId = Process.myTid() + val previousPriority = Process.getThreadPriority(threadId) + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) + task.run() + } finally { + Process.setThreadPriority(previousPriority) + } + } + false + } + ) + } + } + } + + private fun scheduleAfterContextIdle(task: Runnable) { + handler.post { + Looper.myQueue().addIdleHandler( + MessageQueue.IdleHandler { + task.run() + false + } + ) + } + } + override fun destroy() { } 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/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-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRAccessibilityImportanceTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRAccessibilityImportanceTest.kt new file mode 100644 index 000000000..6fef3bd8c --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/css/ktx/KRAccessibilityImportanceTest.kt @@ -0,0 +1,99 @@ +/* + * 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 android.view.accessibility.AccessibilityNodeInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class KRAccessibilityImportanceTest { + @Test + fun hiddenRoleExcludesTheEntireNativeSubtree() { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + resolveAccessibilityImportance(description = "", role = "hidden") + ) + } + + @Test + fun testTagDoesNotExposeAHiddenNativeSubtree() { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, + resolveTestTagAccessibilityImportance(role = "hidden") + ) + } + + @Test + fun testTagRestoresAContainerAfterHiddenRoleIsCleared() { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_YES, + resolveTestTagAccessibilityImportance(role = "none") + ) + } + + @Test + fun noneRoleRestoresDescendantTraversal() { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_NO, + resolveAccessibilityImportance(description = "", role = "none") + ) + } + + @Test + fun describedRoleRemainsAccessible() { + assertEquals( + View.IMPORTANT_FOR_ACCESSIBILITY_YES, + resolveAccessibilityImportance(description = "Search", role = TextViewRole) + ) + } + + @Test + fun hiddenNodeInfoExposesNoFocusableOrActionableSemantics() { + val info = AccessibilityNodeInfo.obtain().apply { + isVisibleToUser = true + isFocusable = true + isClickable = true + isLongClickable = true + text = "Search" + contentDescription = "Search" + addAction(AccessibilityNodeInfo.ACTION_CLICK) + addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) + } + + configureHiddenAccessibilityNodeInfo(info) + + assertFalse(info.isVisibleToUser) + assertFalse(info.isFocusable) + assertFalse(info.isClickable) + assertFalse(info.isLongClickable) + assertNull(info.text) + assertNull(info.contentDescription) + assertEquals(0, info.actions and AccessibilityNodeInfo.ACTION_CLICK) + assertEquals(0, info.actions and AccessibilityNodeInfo.ACTION_LONG_CLICK) + } + + private companion object { + const val TextViewRole = "android.widget.TextView" + } +} 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" + } +} 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)) + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRSelectableTextViewTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRSelectableTextViewTest.kt new file mode 100644 index 000000000..c32e2dbff --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRSelectableTextViewTest.kt @@ -0,0 +1,244 @@ +/* + * 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.app.Activity +import android.content.Context +import android.graphics.Color +import android.os.Looper +import android.os.SystemClock +import android.view.Gravity +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.accessibility.AccessibilityNodeInfo +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.accessibilityTestTagProjection +import com.tencent.kuikly.core.render.android.css.ktx.applyKuiklyAccessibilityExtras +import com.tencent.kuikly.core.render.android.css.ktx.getViewData +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Behavior contract of the system-selectable plain text surface: + * always selectable (system ActionMode source), never editable, truthful + * accessibility, and props arrive over the shared wire keys. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class KRSelectableTextViewTest { + + // putViewData/getViewData only operate when view.context is an + // IKuiklyRenderContext; a bare Application context silently no-ops them. + // Build the view on a real KuiklyRenderView-owned context so testTag + // storage and the a11y projection run the true production path. + private fun createView(): KRSelectableTextView { + val renderView = KuiklyRenderView(RuntimeEnvironment.getApplication()) + return KRSelectableTextView(renderView.kuiklyRenderContext as Context) + } + + @Test + fun selectableAndReadOnlyByConstruction() { + val view = createView() + assertTrue(view.isTextSelectable) + // TextView (not EditText): no input connection, no IME surface. + assertFalse(view.onCheckIsTextEditor()) + // Selection must not leak across cell reuse. + assertFalse(view.reusable) + } + + @Test + fun textPropRendersAndStaysSelectable() { + val view = createView() + assertTrue(view.setProp(KRSelectableTextView.PROP_TEXT, "hello selectable")) + assertEquals("hello selectable", view.text.toString()) + assertTrue(view.isTextSelectable) + } + + @Test + fun colorPropParsesKuiklyColorString() { + val view = createView() + // 4278255360 == 0xFF00FF00 (opaque green) in the Kuikly wire format. + assertTrue(view.setProp(KRSelectableTextView.PROP_COLOR, "4278255360")) + assertEquals(Color.GREEN, view.currentTextColor) + } + + @Test + fun textAlignPropUpdatesHorizontalGravityOnly() { + val view = createView() + assertTrue(view.setProp(KRSelectableTextView.PROP_TEXT_ALIGN, "center")) + assertEquals( + Gravity.CENTER_HORIZONTAL, + view.gravity and Gravity.HORIZONTAL_GRAVITY_MASK + ) + assertEquals(Gravity.TOP, view.gravity and Gravity.VERTICAL_GRAVITY_MASK) + + assertTrue(view.setProp(KRSelectableTextView.PROP_TEXT_ALIGN, "right")) + assertEquals(Gravity.RIGHT, view.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) + + assertTrue(view.setProp(KRSelectableTextView.PROP_TEXT_ALIGN, "left")) + assertEquals(Gravity.LEFT, view.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) + } + + private fun assertTruthfulSelectionNodeInfo(view: KRSelectableTextView) { + assertTrue(view.isLongClickable) + assertTrue(view.isClickable) + assertTrue(view.isTextSelectable) + + val info = view.createAccessibilityNodeInfo() + // Final-layer truth: the node info derives from the real view flags + // regardless of the declined compose boolean mask. + assertTrue(info.isLongClickable) + assertTrue(info.isClickable) + assertTrue( + info.actionList.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK) + ) + assertTrue( + info.actionList.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_SELECTION) + ) + } + + /** + * Robolectric's createAccessibilityNodeInfo does not route through the + * attached View.AccessibilityDelegate (host-model limitation), so the + * composed output is certified by reproducing the real framework order on + * one node: the host populates it first (View/TextView internal state via + * onInitializeAccessibilityNodeInfo, which also runs this view's + * final-layer correction), then the SAME attached production delegate + * applies its extras. No flag is hand-written; every value flows from the + * real view state through real production methods. Real uiautomator + * testTag + long-clickable readouts stay part of the fresh Alpha + * blind-test contract. + */ + private fun nodeInfoThroughRealHostAndDelegate(view: KRSelectableTextView): AccessibilityNodeInfo { + // The Kuikly delegate must be attached; its onInitializeAccessibility- + // NodeInfo is super-populate + applyKuiklyAccessibilityExtras (single + // source, see KRCSSViewExtension). + assertNotNull("Kuikly a11y delegate must be attached", view.accessibilityDelegate) + val info = AccessibilityNodeInfo.obtain() + // Host population first — the same step createAccessibilityNodeInfo + // performs before delegate extras on a real device. + view.onInitializeAccessibilityNodeInfo(info) + // Then the SAME production extras logic the attached delegate runs. + // Robolectric's node-info plumbing does not reliably route the + // delegate's own invocation, so the shared production helper is + // executed directly — execution evidence for the extras branch. + view.applyKuiklyAccessibilityExtras(info) + return info + } + + private fun assertDelegateOutputKeepsContract(view: KRSelectableTextView) { + // The declined mask must not resurface through the delegate, and the + // delegate-owned props must still be produced on a host-populated node. + val delegateInfo = nodeInfoThroughRealHostAndDelegate(view) + assertTrue(delegateInfo.isLongClickable) + assertTrue(delegateInfo.isClickable) + assertTrue( + delegateInfo.actionList.contains( + AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK + ) + ) + // The production testTag -> viewIdResourceName projection: the extras + // helper (executed above) applies exactly this value. Robolectric's + // ShadowAccessibilityNodeInfo does not implement viewIdResourceName + // storage, so the final native-node readout stays a device + // (uiautomator) hard gate in the fresh Alpha blind test. + assertEquals("selectable_text_tag", view.accessibilityTestTagProjection()) + // TEST_TAG storage feeding the projection is present. + assertEquals( + "selectable_text_tag", + view.getViewData(KRCssConst.TEST_TAG) + ) + } + + @Test + fun accessibilityMaskDeclinedWhenItArrivesAfterOtherA11yProps() { + val view = createView() + assertTrue(view.setProp(KRCssConst.TEST_TAG, "selectable_text_tag")) + assertTrue(view.setProp(KRCssConst.ACCESSIBILITY_INFO, "0 0")) + + assertTruthfulSelectionNodeInfo(view) + assertDelegateOutputKeepsContract(view) + } + + @Test + fun accessibilityMaskDeclinedWhenItArrivesBeforeOtherA11yProps() { + val view = createView() + assertTrue(view.setProp(KRCssConst.ACCESSIBILITY_INFO, "0 0")) + assertTrue(view.setProp(KRCssConst.TEST_TAG, "selectable_text_tag")) + + assertTruthfulSelectionNodeInfo(view) + assertDelegateOutputKeepsContract(view) + } + + @Test + fun directLongPressStreamStartsSystemWordSelection() { + // Base capability tooth: the view itself turns a raw DOWN -> + // long-press timeout -> UP stream into system word selection. The + // superTouch/native-capture wiring is covered separately by + // KRViewSuperTouchDispatchTest. + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val view = createView() + view.setProp(KRSelectableTextView.PROP_TEXT, "selectable body text") + activity.setContentView(view) + shadowOf(Looper.getMainLooper()).idle() + + val downTime = SystemClock.uptimeMillis() + val x = view.width / 2f + val y = view.height / 2f + assertTrue( + view.dispatchTouchEvent( + MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0) + ) + ) + shadowOf(Looper.getMainLooper()) + .idleFor(Duration.ofMillis(ViewConfiguration.getLongPressTimeout() + 100L)) + view.dispatchTouchEvent( + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0) + ) + shadowOf(Looper.getMainLooper()).idle() + + // Certifies dispatch/state only (Robolectric cannot host the real OS + // ActionMode): the stream reached the view and word selection started. + assertTrue(view.hasSelection()) + } + + @Test + fun handledPropsPinTheSharedWireContract() { + assertEquals( + setOf( + "text", + "fontSize", + "fontWeight", + "color", + "lineHeight", + "textAlign", + "useDpFontSizeDim" + ), + KRSelectableTextView.HANDLED_PROPS + ) + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRViewSuperTouchDispatchTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRViewSuperTouchDispatchTest.kt new file mode 100644 index 000000000..2ca814a9a --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/KRViewSuperTouchDispatchTest.kt @@ -0,0 +1,148 @@ +/* + * 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.app.Activity +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.ViewGroup +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Wiring contract of the failure found on device (task #69 runtime reopen): + * a compose-hosted root (KRView with superTouch) either withholds the whole + * MotionEvent stream from native children (barrier resolved: capture) or + * delivers it completely (SelectableText region resolved: release). The + * capture decision is latched at ACTION_DOWN and stays sticky per gesture. + * + * The compose side of the seam — real release/capture modifier nodes + * resolving to the boolean this test drives — is pinned by + * NativeDispatchPolicyTest in compose commonTest. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class KRViewSuperTouchDispatchTest { + + private class TouchRecorder { + var dispatchCount = 0 + var lastAction = -1 + } + + private fun buildTree(): Triple { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = KRView(activity) + root.setProp(SUPER_TOUCH_PROP, true) + val child = KRSelectableTextView(activity) + val recorder = TouchRecorder() + // Observes the stream without subclassing the production class; the + // listener returns false so the TextView's real selection handling + // (long-press etc.) still runs. + child.setOnTouchListener { _, event -> + recorder.dispatchCount++ + recorder.lastAction = event.actionMasked + false + } + child.setProp(KRSelectableTextView.PROP_TEXT, "selectable body text") + root.addView( + child, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + activity.setContentView(root) + shadowOf(Looper.getMainLooper()).idle() + return Triple(recorder, root, child) + } + + private fun motion(downTime: Long, action: Int, x: Float = 5f, y: Float = 5f): MotionEvent = + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + + @Test + fun captureRequestedWithholdsTheEntireStreamFromTheNativeChild() { + val (recorder, root, _) = buildTree() + // What SuperTouchManager writes when HitPathTracker resolves CAPTURE + // (barrier hit outside any release region). + root.setProp(NATIVE_DISPATCH_CAPTURE_PROP, true) + + val downTime = SystemClock.uptimeMillis() + assertTrue(root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_DOWN))) + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_MOVE)) + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_UP)) + + assertEquals(0, recorder.dispatchCount) + } + + @Test + fun captureDecisionIsStickyForTheWholeGesture() { + val (recorder, root, _) = buildTree() + root.setProp(NATIVE_DISPATCH_CAPTURE_PROP, true) + + val downTime = SystemClock.uptimeMillis() + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_DOWN)) + // A mid-gesture request change must not leak events into this gesture. + root.setProp(NATIVE_DISPATCH_CAPTURE_PROP, false) + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_MOVE)) + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_UP)) + assertEquals(0, recorder.dispatchCount) + + // The next gesture consumes the updated (released) decision. + val secondDown = SystemClock.uptimeMillis() + root.dispatchTouchEvent(motion(secondDown, MotionEvent.ACTION_DOWN)) + assertTrue(recorder.dispatchCount > 0) + root.dispatchTouchEvent(motion(secondDown, MotionEvent.ACTION_UP)) + } + + @Test + fun releaseResolvedDeliversTheFullStreamAndLongPressStartsSelection() { + val (recorder, root, child) = buildTree() + // What SuperTouchManager writes when the SelectableText release region + // is on the hit branch (HitPathTracker resolves no capture). + root.setProp(NATIVE_DISPATCH_CAPTURE_PROP, false) + + val downTime = SystemClock.uptimeMillis() + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_DOWN)) + assertTrue(recorder.dispatchCount > 0) + assertEquals(MotionEvent.ACTION_DOWN, recorder.lastAction) + + shadowOf(Looper.getMainLooper()) + .idleFor(Duration.ofMillis(ViewConfiguration.getLongPressTimeout() + 100L)) + root.dispatchTouchEvent(motion(downTime, MotionEvent.ACTION_UP)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(MotionEvent.ACTION_UP, recorder.lastAction) + // Dispatch/state certification only: the complete stream reached the + // native child through the superTouch root and word selection started. + assertTrue(child.hasSelection()) + } + + private companion object { + // Wire keys consumed by KRView.setProp (companion consts are private + // in KRView; the wire strings are the cross-layer contract). + const val SUPER_TOUCH_PROP = "superTouch" + const val NATIVE_DISPATCH_CAPTURE_PROP = "nativeDispatchCapture" + } +} 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) + } +} 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) + } +} 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..74f867bef --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanGlyphTest.kt @@ -0,0 +1,60 @@ +/* + * 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 { + + // 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 sameMetricsResolveToSameLineBoxRegardlessOfText() { + val span = HRLineHeightSpan(20) + fun metrics() = Paint.FontMetricsInt().apply { + top = -12; ascent = -12; descent = 4; bottom = 4 + } + + val short = metrics() + val tall = metrics() + span.chooseHeight("as", 0, 2, 0, 20, short) + span.chooseHeight("asf", 0, 3, 0, 20, tall) + + 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 exactLineHeightIsKeptForOddHeights() { + val span = HRLineHeightSpan(17) + val metrics = Paint.FontMetricsInt().apply { + top = -10; ascent = -10; descent = 3; bottom = 3 + } + + span.chooseHeight("x", 0, 1, 0, 17, metrics) + + assertEquals(17, metrics.bottom - metrics.top) + assertEquals(metrics.top, metrics.ascent) + assertEquals(metrics.bottom, metrics.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 new file mode 100644 index 000000000..5ddca250d --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/HRLineHeightSpanTest.java @@ -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.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); + } + + @Test + public void lineHeightDistributesFromCssFontMetricsWhenFontPaddingDiffers() { + 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); + } + + @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); + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRCustomUnderlineSpanTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRCustomUnderlineSpanTest.kt new file mode 100644 index 000000000..c958df508 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRCustomUnderlineSpanTest.kt @@ -0,0 +1,380 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Path +import android.graphics.Region +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ReplacementSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class KRCustomUnderlineSpanTest { + + @Test + fun colorAndThicknessUseOneCustomUnderlineMechanism() { + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 4f, + offset = null, + ) + + assertTrue(span is KRSkipInkCustomUnderlineSpan) + assertFalse(span is ReplacementSpan) + val marker = span as KRSkipInkCustomUnderlineSpan + val paint = testPaint().apply { + isUnderlineText = true + underlineColor = Color.RED + underlineThickness = 1f + } + marker.updateDrawState(paint) + + assertFalse(paint.isUnderlineText) + assertEquals(0, paint.underlineColor) + assertEquals(0f, paint.underlineThickness) + assertTrue(renderBluePixelCount(marker) > 0) + } + + @Test + fun colorOnlyUsesNonZeroPlatformDefaultThicknessAndActuallyDraws() { + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = null, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + assertTrue(renderBluePixelCount(span) > 0) + } + + @Test + fun customUnderlineRemainsBreakableAcrossRealStaticLayoutLines() { + val text = "a long decorated link label that must wrap across multiple lines" + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 2f, + offset = null, + ) + val spannable = SpannableString(text).apply { + setSpan(span, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + val layout = buildLayout(spannable, width = 120) + + assertTrue(layout.lineCount > 1) + assertEquals( + 0, + spannable.getSpans(0, spannable.length, ReplacementSpan::class.java).size, + ) + assertEquals( + 1, + spannable.getSpans(0, spannable.length, KRSkipInkCustomUnderlineSpan::class.java).size, + ) + val bitmap = render(layout) + repeat(layout.lineCount) { line -> + val bluePixels = + (layout.getLineTop(line) until layout.getLineBottom(line)).sumOf { y -> + (0 until bitmap.width).count { x -> bitmap.getPixel(x, y) == Color.BLUE } + } + assertTrue("line $line must contain a custom underline segment", bluePixels > 0) + } + } + + @Test + fun resolvedCurrentColorAndGlyphOutlineProduceNarrowSkipInkGap() { + val span = createKRCustomUnderlineSpan( + color = null, + thickness = 4f, + offset = 2f, + ) as KRSkipInkCustomUnderlineSpan + val value = "aqa" + val text = styledText(span, value, foregroundColor = Color.BLUE) + val layout = buildLayout(text, width = 240) + val bitmap = render(layout) + val underlineRows = underlineBandRows(layout, line = 0, thickness = 4f, offset = 2f) + val glyphLeft = layout.getPrimaryHorizontal(1).toInt() + val glyphRight = layout.getPrimaryHorizontal(2).toInt() + val transparentGapPixels = + underlineRows.sumOf { y -> + (glyphLeft until glyphRight).count { x -> + bitmap.getPixel(x, y) == Color.TRANSPARENT + } + } + val blueBeforeGap = + underlineRows.sumOf { y -> + (0 until glyphLeft).count { x -> bitmap.getPixel(x, y) == Color.BLUE } + } + val blueAfterGap = + underlineRows.sumOf { y -> + (glyphRight until bitmap.width).count { x -> bitmap.getPixel(x, y) == Color.BLUE } + } + val bluePixels = countPixels(bitmap, Color.BLUE) + val blackPixels = + countPixels(bitmap, Color.BLACK) + + assertTrue(bluePixels > 2) + assertTrue("currentColor underline must exist before the q gap", blueBeforeGap > 0) + assertTrue("currentColor underline must exist after the q gap", blueAfterGap > 0) + assertTrue("same-color glyphs must leave a visible background skip gap", transparentGapPixels > 0) + assertEquals("null decoration color must inherit resolved link blue", 0, blackPixels) + + val centerRow = layout.getLineBaseline(0) + 2 + val centerRowGapWidth = + (glyphLeft until glyphRight).count { x -> + bitmap.getPixel(x, centerRow) == Color.TRANSPARENT + } + assertTrue("q descender must interrupt the center underline row", centerRowGapWidth > 0) + assertTrue("q skip halo must remain visible at production scale", centerRowGapWidth >= 2) + assertTrue( + "skip-ink must clear only the q outline, not its full advance", + centerRowGapWidth < glyphRight - glyphLeft, + ) + } + + @Test + fun softWrapLineEndGlyphUsesSameLinePositionForSkipGap() { + val span = createKRCustomUnderlineSpan( + color = null, + thickness = 4f, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + val value = "aaaaagx" + val width = testPaint().measureText("aaaaag").toInt() + 1 + val text = styledText(span, value, foregroundColor = Color.BLUE) + val layout = buildLayout(text, width = width) + val bitmap = render(layout) + + assertEquals(6, layout.getLineVisibleEnd(0)) + assertEquals('g', value[layout.getLineVisibleEnd(0) - 1]) + val glyphLeft = layout.getPrimaryHorizontal(5).toInt() + val glyphRight = (glyphLeft + testPaint().measureText("g")).toInt() + val gapPixels = + underlineBandRows(layout, line = 0, thickness = 4f).sumOf { y -> + (glyphLeft until glyphRight).count { x -> + bitmap.getPixel(x, y) == Color.TRANSPARENT + } + } + val trailingBlue = + underlineBandRows(layout, line = 0, thickness = 4f).sumOf { y -> + (layout.getLineRight(0).toInt() until bitmap.width).count { x -> + bitmap.getPixel(x, y) == Color.BLUE + } + } + + assertTrue("soft-wrap line-end g must punch a gap at its real x", gapPixels > 0) + assertEquals("soft-wrap line must not underline unused trailing width", 0, trailingBlue) + } + + @Test + fun backgroundColorSpanDoesNotCoverOverlayUnderline() { + val span = createKRCustomUnderlineSpan( + color = null, + thickness = 4f, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + val bitmap = + render( + span = span, + value = "link", + foregroundColor = Color.BLUE, + backgroundColor = Color.YELLOW, + ) + + assertTrue(countPixels(bitmap, Color.BLUE) > 0) + } + + @Test + fun hardNewlineDoesNotUnderlineTrailingBlankArea() { + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 4f, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + val value = "abc\nx" + val text = styledText(span, value) + val layout = buildLayout(text, width = 240) + val bitmap = render(layout) + val actualEnd = testPaint().measureText("abc").toInt() + val trailingBlue = + underlineBandRows(layout, line = 0, thickness = 4f).sumOf { y -> + (actualEnd until bitmap.width).count { x -> bitmap.getPixel(x, y) == Color.BLUE } + } + + assertEquals(0, trailingBlue) + } + + @Test + fun mixedBidiUnderlineNeverEscapesLayoutSelectionGeometry() { + val value = "אב12cdEF" + val start = 1 + val end = 7 + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 4f, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + val text = SpannableString(value).apply { + setSpan(span, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val layout = buildLayout(text, width = 240) + val bitmap = render(layout) + val selectionPath = Path().also { layout.getSelectionPath(start, end, it) } + val selectionRegion = Region().apply { + setPath(selectionPath, Region(0, 0, bitmap.width, bitmap.height)) + } + var bluePixels = 0 + + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getPixel(x, y) == Color.BLUE) { + bluePixels++ + assertTrue("blue pixel escaped bidi selection at $x,$y", selectionRegion.contains(x, y)) + } + } + } + assertTrue("mixed bidi range must draw at least one underline pixel", bluePixels > 0) + } + + @Test + @Config(sdk = [28]) + fun api21To28DrawsExactCustomUnderlineWithoutNonSdkPaintFields() { + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 4f, + offset = null, + ) as KRSkipInkCustomUnderlineSpan + + assertTrue(renderBluePixelCount(span) > 0) + } + + @Test + fun explicitOffsetUsesWrapSafeDrawerPath() { + val span = createKRCustomUnderlineSpan( + color = Color.BLUE, + thickness = 2f, + offset = 3f, + ) + + assertTrue(span is KRSkipInkCustomUnderlineSpan) + assertFalse(span is ReplacementSpan) + val marker = span as KRSkipInkCustomUnderlineSpan + assertEquals(3f, marker.offset) + + val text = styledText(marker, "a long link that wraps over several rows") + val layout = buildLayout(text, width = 120) + val bitmap = render(layout) + assertTrue(layout.lineCount > 1) + repeat(layout.lineCount) { line -> + val underlineRows = underlineBandRows(layout, line, thickness = 2f, offset = 3f) + val bluePixels = + underlineRows.sumOf { y -> + (0 until bitmap.width).count { x -> bitmap.getPixel(x, y) == Color.BLUE } + } + assertTrue("explicit-offset underline must remain on wrapped line $line", bluePixels > 0) + } + } + + private fun renderBluePixelCount(span: KRSkipInkCustomUnderlineSpan): Int { + val bitmap = render(span, "Underline") + var bluePixels = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + if (bitmap.getPixel(x, y) == Color.BLUE) bluePixels++ + } + } + return bluePixels + } + + private fun render( + span: KRSkipInkCustomUnderlineSpan, + value: String, + foregroundColor: Int? = null, + backgroundColor: Int? = null, + ): Bitmap { + val text = styledText(span, value, foregroundColor, backgroundColor) + val layout = buildLayout(text, width = 240) + return render(layout) + } + + private fun styledText( + span: KRSkipInkCustomUnderlineSpan, + value: String, + foregroundColor: Int? = null, + backgroundColor: Int? = null, + ): SpannableString = + SpannableString(value).apply { + setSpan(span, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if (foregroundColor != null) { + setSpan( + ForegroundColorSpan(foregroundColor), + 0, + length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + if (backgroundColor != null) { + setSpan( + BackgroundColorSpan(backgroundColor), + 0, + length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + private fun render(layout: StaticLayout): Bitmap { + val bitmap = Bitmap.createBitmap(layout.width, maxOf(96, layout.height), Bitmap.Config.ARGB_8888) + KRRichTextViewDrawer(layout).draw(Canvas(bitmap)) + return bitmap + } + + private fun underlineBandRows( + layout: StaticLayout, + line: Int, + thickness: Float, + offset: Float = testPaint().underlinePosition, + ): IntRange { + val center = layout.getLineBaseline(line) + offset + return (center - thickness / 2f).toInt()..(center + thickness / 2f).toInt() + } + + private fun countPixels(bitmap: Bitmap, color: Int): Int = + (0 until bitmap.height).sumOf { y -> + (0 until bitmap.width).count { x -> bitmap.getPixel(x, y) == color } + } + + private fun buildLayout(text: CharSequence, width: Int): StaticLayout = + StaticLayout.Builder + .obtain(text, 0, text.length, testPaint(), width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .build() + + private fun testPaint(): TextPaint = + TextPaint().apply { + color = Color.BLACK + textSize = 40f + isAntiAlias = false + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxChromeTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxChromeTest.kt new file mode 100644 index 000000000..68cf0def0 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxChromeTest.kt @@ -0,0 +1,289 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Region +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.ReplacementSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class KRInlineBoxChromeTest { + + @Test + fun wrappedGroupChromeDoesNotPaintAdjacentPlainText() { + val prefix = "Long path without ellipsis: " + val code = "reply/channel-markdown/message-inline-visual/owner-key-consumes-thread-route" + val suffix = " suffix" + val leadingEdge = '\uFFFC' + val joiner = INLINE_BOX_LAYOUT_JOINER + val value = prefix + leadingEdge + joiner + code + joiner + leadingEdge + suffix + val groupStart = prefix.length + val groupEnd = value.length - suffix.length + val text = SpannableString(value).apply { + setSpan(FixedAdvanceSpan(8), groupStart, groupStart + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan(FixedAdvanceSpan(8), groupEnd - 1, groupEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + KRInlineBoxSpan( + KRInlineBoxSpanStyle( + backgroundColor = Color.RED, + borderColor = null, + borderWidth = 0f, + paddingStart = 8f, + paddingEnd = 8f, + paddingTop = 0f, + paddingBottom = 0f, + marginStart = 0f, + marginEnd = 0f, + cornerRadius = 0f, + ) + ), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + val layout = buildLayout(text, width = 320) + val bitmap = render(layout) + + assertTrue("fixture must start the group after plain text", groupStart > layout.getLineStart(0)) + assertTrue("fixture must wrap the inline group", layout.getLineForOffset(groupEnd - 1) > 0) + for (line in layout.getLineForOffset(groupStart)..layout.getLineForOffset(groupEnd - 1)) { + val segmentStart = maxOf(groupStart, layout.getLineStart(line)) + val segmentEnd = minOf(groupEnd, layout.getLineVisibleEnd(line)) + if (segmentEnd <= segmentStart) continue + val bounds = selectionBounds(layout, segmentStart, segmentEnd, line) + var redPixels = 0 + for (y in layout.getLineTop(line) until layout.getLineBottom(line)) { + for (x in 0 until bitmap.width) { + if (bitmap.getPixel(x, y) != Color.RED) continue + redPixels++ + assertTrue( + "inline-box fill escaped line $line selection at $x,$y; bounds=$bounds", + x >= bounds.left.toInt() && x < kotlin.math.ceil(bounds.right).toInt(), + ) + } + } + assertTrue("line $line must contain inline-box fill", redPixels > 0) + } + + val firstLine = layout.getLineForOffset(groupStart) + val prefixRight = layout.getPrimaryHorizontal(groupStart).toInt() + val redOverPrefix = + (layout.getLineTop(firstLine) until layout.getLineBottom(firstLine)).sumOf { y -> + (0 until prefixRight).count { x -> bitmap.getPixel(x, y) == Color.RED } + } + assertEquals("plain prefix must never receive inline-box fill", 0, redOverPrefix) + } + + @Test + fun mixedBidirectionalGroupChromeClipsDisjointSelection() { + val prefix = "prefix " + val code = "abc אב" + val suffix = "ג xyz suffix" + val value = prefix + code + suffix + val groupStart = prefix.length + val groupEnd = groupStart + code.length + val text = SpannableString(value).apply { + setSpan( + KRInlineBoxSpan( + KRInlineBoxSpanStyle( + backgroundColor = Color.RED, + borderColor = null, + borderWidth = 0f, + paddingStart = 0f, + paddingEnd = 0f, + paddingTop = 0f, + paddingBottom = 0f, + marginStart = 0f, + marginEnd = 0f, + cornerRadius = 0f, + ) + ), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + val layout = buildLayout(text, width = 640) + val line = layout.getLineForOffset(groupStart) + assertEquals(line, layout.getLineForOffset(groupEnd - 1)) + val selection = selectionPath(layout, groupStart, groupEnd, line) + val selectionRegion = selectionRegion(layout, selection, line) + val sampleY = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2 + assertTrue( + "fixture must produce a disjoint bidi selection", + horizontalRunCount(selectionRegion, sampleY, layout.width) > 1, + ) + + val bitmap = render(layout) + var redPixels = 0 + for (y in layout.getLineTop(line) until layout.getLineBottom(line)) { + for (x in 0 until bitmap.width) { + if (bitmap.getPixel(x, y) != Color.RED) continue + redPixels++ + assertTrue( + "inline-box fill escaped bidi selection at $x,$y", + selectionRegion.contains(x, y), + ) + } + } + assertTrue("bidi selection must contain inline-box fill", redPixels > 0) + } + + @Test + fun wrappedGroupChromeStopsAtShortFirstFragment() { + val prefix = "Command: " + val code = "./\u200Bgradlew\u200B:shared:testDebugUnitTest" + val value = prefix + code + val groupStart = prefix.length + val groupEnd = value.length + val text = SpannableString(value).apply { + setSpan( + KRInlineBoxSpan( + KRInlineBoxSpanStyle( + backgroundColor = Color.RED, + borderColor = null, + borderWidth = 0f, + paddingStart = 0f, + paddingEnd = 0f, + paddingTop = 0f, + paddingBottom = 0f, + marginStart = 0f, + marginEnd = 0f, + cornerRadius = 0f, + ) + ), + groupStart, + groupEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + val paint = testPaint() + val width = kotlin.math.ceil(paint.measureText(prefix + "./") + 2f).toInt() + val layout = buildLayout(text, width = width, paint = paint) + val firstLine = layout.getLineForOffset(groupStart) + assertTrue("fixture must wrap after the short ./ fragment", layout.lineCount > firstLine + 1) + assertTrue( + "first line must end before gradlew", + layout.getLineEnd(firstLine) <= groupStart + "./\u200B".length, + ) + + val bitmap = render(layout) + val contentRight = kotlin.math.ceil(layout.getLineRight(firstLine).toDouble()).toInt() + assertTrue("fixture must leave an unused first-line tail", contentRight < bitmap.width) + val redTailPixels = + (layout.getLineTop(firstLine) until layout.getLineBottom(firstLine)).sumOf { y -> + (contentRight until bitmap.width).count { x -> bitmap.getPixel(x, y) == Color.RED } + } + assertEquals("chrome after ./ must not fill the unused line tail", 0, redTailPixels) + } + + private fun selectionBounds(layout: Layout, start: Int, end: Int, line: Int): RectF { + val selection = selectionPath(layout, start, end, line) + return RectF().also { selection.computeBounds(it, true) } + } + + private fun selectionPath(layout: Layout, start: Int, end: Int, line: Int): Path { + val selection = Path().also { layout.getSelectionPath(start, end, it) } + val lineLeft = minOf(layout.getLineLeft(line), layout.getLineRight(line)) + val lineRight = maxOf(layout.getLineLeft(line), layout.getLineRight(line)) + val lineClip = Path().apply { + addRect( + lineLeft, + layout.getLineTop(line).toFloat(), + lineRight, + layout.getLineBottom(line).toFloat(), + Path.Direction.CW, + ) + } + assertTrue(selection.op(lineClip, Path.Op.INTERSECT)) + return selection + } + + private fun selectionRegion(layout: Layout, selection: Path, line: Int): Region = + Region().apply { + setPath( + selection, + Region(0, layout.getLineTop(line), layout.width, layout.getLineBottom(line)), + ) + } + + private fun horizontalRunCount(region: Region, y: Int, width: Int): Int { + var runs = 0 + var inside = false + for (x in 0 until width) { + val nextInside = region.contains(x, y) + if (nextInside && !inside) runs++ + inside = nextInside + } + return runs + } + + private fun buildLayout( + text: CharSequence, + width: Int, + paint: TextPaint = testPaint(), + ): StaticLayout = + StaticLayout.Builder + .obtain(text, 0, text.length, paint, width) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .build() + + private fun render(layout: StaticLayout): Bitmap = + Bitmap.createBitmap(layout.width, layout.height, Bitmap.Config.ARGB_8888).also { bitmap -> + KRRichTextViewDrawer(layout).draw(Canvas(bitmap)) + } + + private fun testPaint(): TextPaint = + TextPaint().apply { + color = Color.BLACK + textSize = 40f + isAntiAlias = false + } + + private class FixedAdvanceSpan(private val width: Int) : ReplacementSpan() { + override fun getSize( + paint: android.graphics.Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: android.graphics.Paint.FontMetricsInt?, + ): Int = width + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: android.graphics.Paint, + ) = Unit + } +} 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..9390154d6 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/component/text/KRInlineBoxSpanStyleTest.kt @@ -0,0 +1,191 @@ +/* + * 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 android.util.SizeF +import com.tencent.kuikly.core.render.android.expand.component.KRTextProps +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class KRInlineBoxSpanStyleTest { + + @Test + fun inlineBoxGroupLineStartReadsTheRichTextBuilderReceiver() { + assertEquals(true, "".isInlineBoxGroupAtLineStart()) + assertEquals(true, "prefix\n".isInlineBoxGroupAtLineStart()) + assertEquals(false, "prefix".isInlineBoxGroupAtLineStart()) + } + + @Test + fun twoChildInlineBoxBuildDoesNotReadPastAnEmptyRichTextBuilder() { + val children = JSONArray() + .put(JSONObject().put(KRTextProps.PROP_KEY_TEXT, "first")) + // Still runs parseSpanProps after the first child has entered buildList, which is + // the exact receiver-shadowing state, while keeping the successful path atomic. + .put(JSONObject().put(KRTextProps.PROP_KEY_TEXT, "")) + val group = JSONObject() + .put(InlineBoxGroupSpanProps.PROP_KEY_CHILDREN, children) + .put("inlineBoxBorderWidth", 0) + val textProps = KRTextProps(null).apply { + values = JSONArray().put(group) + } + + val result = KRRichTextBuilder(null).build(textProps, mutableListOf()) { SizeF(0f, 0f) } + + assertNotNull(result) + assertEquals("first", result.toString()) + } + + @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) + } + + @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), + ), + ) + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModuleTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModuleTest.kt new file mode 100644 index 000000000..dd452fa36 --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KRFileModuleTest.kt @@ -0,0 +1,86 @@ +/* + * 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.core.render.android.expand.module + +import android.content.Context +import com.tencent.kuikly.core.render.android.IKuiklyRenderContext +import java.io.File +import java.lang.reflect.Proxy +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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 KRFileModuleTest { + + @Test + fun duplicateOperationIdAcrossPagerModulesAppendsOnlyOnce() { + val context = RuntimeEnvironment.getApplication().baseContext + val moduleA = fileModule(context) + val moduleB = fileModule(context) + val suffix = System.nanoTime().toString() + val filename = "file-module-dedupe-$suffix.jsonl" + val operationId = "android-file-operation-$suffix" + val params = + "{\"filename\":\"$filename\",\"content\":\"frame\"," + + "\"operationId\":\"$operationId\"}" + val callbacks = CopyOnWriteArrayList>() + val callbackLatch = CountDownLatch(2) + + val callback: (Any?) -> Unit = { result -> + callbacks.add(result as Map<*, *>) + callbackLatch.countDown() + } + + moduleA.call("appendFile", params, callback) + moduleB.call("appendFile", params, callback) + + assertTrue("native file callbacks timed out", callbackLatch.await(5, TimeUnit.SECONDS)) + assertEquals(2, callbacks.size) + assertTrue(callbacks.all { it["error"] == null }) + val paths = callbacks.map { it["path"] as String }.toSet() + assertEquals(1, paths.size) + + val file = File(paths.single()) + try { + assertEquals("frame\n", file.readText(Charsets.UTF_8)) + } finally { + file.delete() + } + } + + private fun fileModule(context: Context): KRFileModule = + KRFileModule().also { module -> + module.kuiklyRenderContext = Proxy.newProxyInstance( + IKuiklyRenderContext::class.java.classLoader, + arrayOf(IKuiklyRenderContext::class.java) + ) { _, method, _ -> + when (method.name) { + "getContext" -> context + "useHostDisplayMetrics" -> false + else -> null + } + } as IKuiklyRenderContext + } +} diff --git a/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KeyboardHeightListenerRegistryTest.kt b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KeyboardHeightListenerRegistryTest.kt new file mode 100644 index 000000000..60fabcc2c --- /dev/null +++ b/core-render-android/src/test/java/com/tencent/kuikly/core/render/android/expand/module/KeyboardHeightListenerRegistryTest.kt @@ -0,0 +1,223 @@ +package com.tencent.kuikly.core.render.android.expand.module + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class KeyboardHeightListenerRegistryTest { + + @Test + fun replacementListenerReceivesVisibleHeightBeforeDismissal() { + val registry = registryWithoutThreadGuard() + val firstValues = mutableListOf() + val firstListener = recordingListener(firstValues) + + registry.addListener(firstListener) + registry.dispatchHeight(900) + registry.removeListener(firstListener) + + val replacementValues = mutableListOf() + registry.addListener(recordingListener(replacementValues)) + registry.dispatchHeight(0) + + assertEquals(listOf(0, 900), firstValues) + assertEquals(listOf(900, 0), replacementValues) + } + + @Test + fun unchangedHeightIsNotRedispatched() { + val registry = registryWithoutThreadGuard() + val values = mutableListOf() + + registry.addListener(recordingListener(values)) + registry.dispatchHeight(640) + registry.dispatchHeight(640) + registry.dispatchHeight(0) + registry.dispatchHeight(0) + + assertEquals(listOf(0, 640, 0), values) + } + + @Test + fun removedListenerDoesNotReceiveLaterTransitions() { + val registry = registryWithoutThreadGuard() + val removedValues = mutableListOf() + val removedListener = recordingListener(removedValues) + + registry.addListener(removedListener) + registry.dispatchHeight(720) + registry.removeListener(removedListener) + registry.dispatchHeight(0) + + assertEquals(listOf(0, 720), removedValues) + } + + @Test + fun replacementForwardsZeroWhenDismissalHappenedWithoutListener() { + val registry = registryWithoutThreadGuard() + var pageInset = 900 + + registry.dispatchHeight(900) + val firstListener = deduplicatingListener { pageInset = it } + registry.addListener(firstListener) + registry.removeListener(firstListener) + + registry.dispatchHeight(0) + assertEquals(900, pageInset) + + registry.addListener(deduplicatingListener { pageInset = it }) + + assertEquals(0, pageInset) + } + + @Test + fun firstHeightIsForwardedThenDuplicatesAreDeduplicated() { + val gate = KeyboardHeightDispatchGate() + + assertEquals(true, gate.accept(0)) + assertEquals(false, gate.accept(0)) + assertEquals(true, gate.accept(900)) + assertEquals(false, gate.accept(900)) + } + + @Test + fun successiveReplacementListenersEachForwardTheirFirstReplay() { + val registry = registryWithoutThreadGuard() + registry.dispatchHeight(900) + + val firstValues = mutableListOf() + val firstListener = deduplicatingListener { firstValues += it } + registry.addListener(firstListener) + registry.removeListener(firstListener) + + val secondValues = mutableListOf() + val secondListener = deduplicatingListener { secondValues += it } + registry.addListener(secondListener) + registry.removeListener(secondListener) + + val thirdValues = mutableListOf() + registry.addListener(deduplicatingListener { thirdValues += it }) + + assertEquals(listOf(900), firstValues) + assertEquals(listOf(900), secondValues) + assertEquals(listOf(900), thirdValues) + } + + private fun recordingListener(values: MutableList): KeyboardStatusListener = + object : KeyboardStatusListener { + override fun onHeightChanged(height: Int) { + values += height + } + } + + private fun deduplicatingListener(onHeightChanged: (Int) -> Unit): KeyboardStatusListener { + val gate = KeyboardHeightDispatchGate() + return object : KeyboardStatusListener { + override fun onHeightChanged(height: Int) { + if (gate.accept(height)) onHeightChanged(height) + } + } + } + + private fun registryWithoutThreadGuard(): KeyboardHeightListenerRegistry = + KeyboardHeightListenerRegistry( + failFastOnThreadViolation = false, + isOnMainThread = { true } + ) +} + +@RunWith(RobolectricTestRunner::class) +class KeyboardHeightListenerRegistryMainThreadTest { + + @Test + fun addListenerRejectsWorkerThread() { + assertWorkerThreadRejected("addListener") { registry -> + registry.addListener(recordingListener()) + } + } + + @Test + fun removeListenerRejectsWorkerThread() { + assertWorkerThreadRejected("removeListener") { registry -> + registry.removeListener(recordingListener()) + } + } + + @Test + fun dispatchHeightRejectsWorkerThread() { + assertWorkerThreadRejected("dispatchHeight") { registry -> + registry.dispatchHeight(640) + } + } + + @Test + fun clearRejectsWorkerThread() { + assertWorkerThreadRejected("clear") { registry -> + registry.clear() + } + } + + @Test + fun releaseModeReportsEveryWorkerThreadEntryWithoutThrowing() { + val violations = mutableListOf() + val registry = + KeyboardHeightListenerRegistry( + failFastOnThreadViolation = false, + isOnMainThread = { false }, + reportThreadViolation = { violations += it } + ) + val listener = recordingListener() + + registry.addListener(listener) + registry.dispatchHeight(640) + registry.removeListener(listener) + registry.clear() + + assertEquals(4, violations.size) + assertTrue(violations[0].contains("addListener")) + assertTrue(violations[1].contains("dispatchHeight")) + assertTrue(violations[2].contains("removeListener")) + assertTrue(violations[3].contains("clear")) + } + + private fun assertWorkerThreadRejected( + operation: String, + call: (KeyboardHeightListenerRegistry) -> Unit + ) { + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "keyboard-registry-worker") + } + try { + val failure = + try { + executor.submit { + call( + KeyboardHeightListenerRegistry( + failFastOnThreadViolation = true + ) + ) + } + .get(5, TimeUnit.SECONDS) + null + } catch (error: ExecutionException) { + error.cause + } + + assertTrue(failure is IllegalStateException) + assertTrue(failure?.message.orEmpty().contains(operation)) + assertTrue(failure?.message.orEmpty().contains("keyboard-registry-worker")) + } finally { + executor.shutdownNow() + } + } + + private fun recordingListener(): KeyboardStatusListener = + object : KeyboardStatusListener { + override fun onHeightChanged(height: Int) = Unit + } +} 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) + } +} diff --git a/core-render-ios/Core/KuiklyRenderCore.m b/core-render-ios/Core/KuiklyRenderCore.m index b54b6554d..faf074935 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]; @@ -263,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/Extension/AdvancedComps/KRRichTextView.h b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h index a761ad645..f257844cf 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.h +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.h @@ -47,10 +47,14 @@ 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; @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; @@ -60,6 +64,11 @@ 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; +// 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 a177b5bdd..29fb1daa9 100644 --- a/core-render-ios/Extension/AdvancedComps/KRRichTextView.m +++ b/core-render-ios/Extension/AdvancedComps/KRRichTextView.m @@ -18,12 +18,259 @@ #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 kKRSlockInlineCodeHorizontalPaddingRatio = 4.0 / 15.0; +static const CGFloat kKRSlockInlineCodeHorizontalMarginRatio = 2.0 / 15.0; +static const CGFloat kKRSlockInlineCodeLineHeightRatio = 1.5; +static const NSUInteger kKRSlockInlineCodeAtomizeThreshold = 16; + +@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 +// 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 * kKRSlockInlineCodeHorizontalPaddingRatio; + CGFloat outerMargin = textSize * kKRSlockInlineCodeHorizontalMarginRatio; + CGFloat edgeAdvance = innerPadding + outerMargin; + CGFloat leadingAdvance = leadingEdge ? edgeAdvance : 0.0; + CGFloat trailingAdvance = trailingEdge ? edgeAdvance : 0.0; + CGFloat atomHeight = textSize * kKRSlockInlineCodeLineHeightRatio; + CGFloat totalWidth = textWidth + leadingAdvance + trailingAdvance; + // A composed grapheme can legitimately reserve zero typographic + // advance (for example a format/control atom in a long atomized run). + // UIKit rejects a zero-sized bitmap context, but the attachment must + // keep that zero logical advance so surrounding glyph layout, chrome, + // wrapping and copy semantics remain unchanged. + CGFloat bitmapWidth = MAX(1.0, totalWidth); + CGFloat bitmapHeight = MAX(1.0, atomHeight); + + UIGraphicsBeginImageContextWithOptions(CGSizeMake(bitmapWidth, bitmapHeight), 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 + @interface KRRichTextView() @property (nonatomic, strong) NSNumber *css_numberOfLines; @@ -305,7 +552,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]; @@ -322,6 +578,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("]) { @@ -330,6 +587,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; @@ -342,8 +602,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) { @@ -374,10 +632,14 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.spanIndex = spanIndex; spanAttrs.font = font; spanAttrs.color = color; + spanAttrs.backgroundColor = backgroundColor; spanAttrs.hasGradient = hasGradient; 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; @@ -387,6 +649,31 @@ - (NSMutableAttributedString *)p_buildAttributedString { spanAttrs.strokeWidth = strokeWidth; spanAttrs.shadow = textShadow; spanAttrs.richAttrArray = richAttrArray; + 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) { @@ -413,8 +700,227 @@ - (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]; + NSString *semantic = span[@"inlineBoxSemanticText"]; + 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]; + if ([semantic isKindOfClass:[NSString class]] && semantic.length > 0) { + [group addAttribute:KRInlineBoxSemanticAttributeName value:semantic range:range]; + } + [group addAttribute:KuiklyIndexAttributeName value:@(spanIndex) range:range]; + return group; +} + +- (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.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]; + } NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:attrs.text attributes:@{}]; NSRange range = NSMakeRange(0, attributedString.length); @@ -449,8 +955,16 @@ - (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]; + 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]; @@ -555,9 +1069,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/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 diff --git a/core-render-ios/Extension/Components/KRScrollView.m b/core-render-ios/Extension/Components/KRScrollView.m index f664514bc..b4f28ef8a 100644 --- a/core-render-ios/Extension/Components/KRScrollView.m +++ b/core-render-ios/Extension/Components/KRScrollView.m @@ -29,6 +29,23 @@ typedef NS_ENUM(NSUInteger, KRSetContentOffsetAnimation) { KRSetContentOffsetAnimationLinear = 1, }; +static BOOL KRScrollEventValueIsFinite(CGFloat value) { + return !isnan(value) && !isinf(value); +} + +static BOOL KRScrollEventPointIsFinite(CGPoint point) { + return KRScrollEventValueIsFinite(point.x) && KRScrollEventValueIsFinite(point.y); +} + +static void KRLogDroppedScrollEventValue(NSString *field, + CGFloat value, + NSString *action) { + NSLog(@"[kuikly error][KRScrollView] non-finite scroll event value field=%@ value=%@ action=%@", + field, + @(value), + action); +} + /* * @brief 暴露给Kotlin侧调用的Scoller组件 */ @@ -36,6 +53,8 @@ @interface KRScrollView()_css_scrollEnd) { - strongSelf->_css_scrollEnd([strongSelf p_generateEventBaseParams]); + NSDictionary *eventParams = [strongSelf p_generateEventBaseParams]; + if (eventParams) { + strongSelf->_css_scrollEnd(eventParams); + } } }]; } @@ -427,11 +464,27 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoi _targetContentOffset = targetContentOffset; NSMutableDictionary *params = [[self p_generateEventBaseParams] mutableCopy]; - params[@"velocityX"] = @(velocity.x); - params[@"velocityY"] = @(velocity.y); - params[@"targetContentOffsetX"] = @((*targetContentOffset).x); - params[@"targetContentOffsetY"] = @((*targetContentOffset).y); - _css_willDragEnd(params); /// setContentOffset () + CGPoint target = targetContentOffset ? *targetContentOffset : CGPointZero; + if (!KRScrollEventValueIsFinite(velocity.x)) { + KRLogDroppedScrollEventValue(@"velocityX", velocity.x, @"drop_event"); + params = nil; + } else if (!KRScrollEventValueIsFinite(velocity.y)) { + KRLogDroppedScrollEventValue(@"velocityY", velocity.y, @"drop_event"); + params = nil; + } else if (!KRScrollEventValueIsFinite(target.x)) { + KRLogDroppedScrollEventValue(@"targetContentOffsetX", target.x, @"drop_event"); + params = nil; + } else if (!KRScrollEventValueIsFinite(target.y)) { + KRLogDroppedScrollEventValue(@"targetContentOffsetY", target.y, @"drop_event"); + params = nil; + } + if (params) { + params[@"velocityX"] = @(velocity.x); + params[@"velocityY"] = @(velocity.y); + params[@"targetContentOffsetX"] = @(target.x); + params[@"targetContentOffsetY"] = @(target.y); + _css_willDragEnd(params); /// setContentOffset () + } _targetContentOffset = nil; } } @@ -500,6 +553,17 @@ - (void)setCss_bouncesEnable:(NSNumber *)css_bouncesEnable { } } +- (void)setCss_keyboardDismissModeInteractiveIOS:(NSNumber *)css_keyboardDismissModeInteractiveIOS { + if (self.css_keyboardDismissModeInteractiveIOS != css_keyboardDismissModeInteractiveIOS) { + _css_keyboardDismissModeInteractiveIOS = css_keyboardDismissModeInteractiveIOS; + #if !TARGET_OS_OSX // [macOS] + self.keyboardDismissMode = [css_keyboardDismissModeInteractiveIOS boolValue] + ? UIScrollViewKeyboardDismissModeInteractive + : UIScrollViewKeyboardDismissModeNone; + #endif // [macOS] + } +} + - (void)setCss_pagingEnabled:(NSNumber *)css_pagingEnabled { if (self.css_pagingEnabled != css_pagingEnabled) { _css_pagingEnabled = css_pagingEnabled; @@ -730,8 +794,8 @@ - (void)dispatchScrollEventWithCurOffset:(CGPoint)curOffset { syncCallback = ![self p_hasEnoughVisibleContentViews]; } NSMutableDictionary *param = [[self p_generateEventBaseParams] mutableCopy]; - param[KR_SYNC_CALLBACK_KEY] = @(syncCallback ? 1 : 0); // 同步加载 - if (self.css_scroll) { + if (param) { + param[KR_SYNC_CALLBACK_KEY] = @(syncCallback ? 1 : 0); // 同步加载 self.css_scroll(param); } }; @@ -792,11 +856,46 @@ - (UIEdgeInsets)maxEdgeInsetsWithContentOffset:(CGPoint)contentOffset { - (NSDictionary *)p_generateEventBaseParams { - + CGFloat coreValues[] = { + _lastContentOffset.x, + _lastContentOffset.y, + self.contentSize.width, + self.contentSize.height, + self.frame.size.width, + self.frame.size.height, + }; + NSArray *coreFields = @[ + @"offsetX", + @"offsetY", + @"contentWidth", + @"contentHeight", + @"viewWidth", + @"viewHeight", + ]; + for (NSUInteger i = 0; i < coreFields.count; i++) { + if (!KRScrollEventValueIsFinite(coreValues[i])) { + KRLogDroppedScrollEventValue(coreFields[i], coreValues[i], @"drop_event"); + return nil; + } + } + NSMutableArray *touchesParam = [NSMutableArray new]; #if !TARGET_OS_OSX // [macOS] for (int i = 0; i < self.panGestureRecognizer.numberOfTouches; i++) { CGPoint pagePoint = [self.panGestureRecognizer locationOfTouch:i inView:self.hr_rootView]; + if (!KRScrollEventPointIsFinite(pagePoint)) { + if (!KRScrollEventValueIsFinite(pagePoint.x)) { + KRLogDroppedScrollEventValue([NSString stringWithFormat:@"touches[%d].pageX", i], + pagePoint.x, + @"drop_touch"); + } + if (!KRScrollEventValueIsFinite(pagePoint.y)) { + KRLogDroppedScrollEventValue([NSString stringWithFormat:@"touches[%d].pageY", i], + pagePoint.y, + @"drop_touch"); + } + continue; + } [touchesParam addObject:@{ @"pageX" : @(pagePoint.x), @"pageY" : @(pagePoint.y) @@ -805,10 +904,19 @@ - (NSDictionary *)p_generateEventBaseParams { #else // [macOS // On macOS, get mouse location to simulate single touch point CGPoint mousePoint = [self kr_mouseLocationInView:self.hr_rootView]; - [touchesParam addObject:@{ - @"pageX" : @(mousePoint.x), - @"pageY" : @(mousePoint.y) - }]; + if (KRScrollEventPointIsFinite(mousePoint)) { + [touchesParam addObject:@{ + @"pageX" : @(mousePoint.x), + @"pageY" : @(mousePoint.y) + }]; + } else { + if (!KRScrollEventValueIsFinite(mousePoint.x)) { + KRLogDroppedScrollEventValue(@"touches[0].pageX", mousePoint.x, @"drop_touch"); + } + if (!KRScrollEventValueIsFinite(mousePoint.y)) { + KRLogDroppedScrollEventValue(@"touches[0].pageY", mousePoint.y, @"drop_touch"); + } + } #endif // macOS] return @{ @@ -849,6 +957,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 +980,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]; }]; } @@ -892,8 +1010,6 @@ - (void)applyTurboDisplayExtraCacheContent:(NSDictionary *)extraCacheProps { } @end - - @interface KRScrollContentView () @property (nonatomic, weak) id delegate; @end @@ -1013,7 +1129,3 @@ - (void)insertSubview:(UIView *)view atIndex:(NSInteger)index { } @end - - - - diff --git a/core-render-ios/Extension/Components/KRSelectableTextView.h b/core-render-ios/Extension/Components/KRSelectableTextView.h new file mode 100644 index 000000000..180a58b5e --- /dev/null +++ b/core-render-ios/Extension/Components/KRSelectableTextView.h @@ -0,0 +1,33 @@ +/* + * 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. + */ + +#import "KRUIKit.h" // [macOS] +#import "KuiklyRenderViewExportProtocol.h" +NS_ASSUME_NONNULL_BEGIN + +/* + * @brief System-selectable read-only plain text. + * + * A UITextView with editable=NO / selectable=YES so the system edit menu + * appears anchored to the selection. Baseline guarantee: Select All / Copy. + * Further items (Look Up / Translate / Share, etc.) appear only when the OS + * version, locale and installed services provide them. Never becomes an + * input surface: no IME, no text mutation except through the "text" prop. + */ +@interface KRSelectableTextView : UITextView + +@end + +NS_ASSUME_NONNULL_END diff --git a/core-render-ios/Extension/Components/KRSelectableTextView.m b/core-render-ios/Extension/Components/KRSelectableTextView.m new file mode 100644 index 000000000..c9058fac9 --- /dev/null +++ b/core-render-ios/Extension/Components/KRSelectableTextView.m @@ -0,0 +1,171 @@ +/* + * 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. + */ + +#import "KRSelectableTextView.h" +#import "KRComponentDefine.h" +#import "KRConvertUtil.h" +#import "UIView+CSS.h" + +@interface KRSelectableTextView() +/** attr is text */ +@property (nonatomic, copy) NSString *KUIKLY_PROP(text); +/** attr is fontSize */ +@property (nonatomic, strong) NSNumber *KUIKLY_PROP(fontSize); +/** attr is fontWeight */ +@property (nonatomic, strong) NSString *KUIKLY_PROP(fontWeight); +/** attr is color */ +@property (nonatomic, strong) NSString *KUIKLY_PROP(color); +/** attr is lineHeight */ +@property (nonatomic, strong) NSNumber *KUIKLY_PROP(lineHeight); +/** attr is textAlign */ +@property (nonatomic, strong) NSString *KUIKLY_PROP(textAlign); +@end + +@implementation KRSelectableTextView + +@synthesize hr_rootView; +#if TARGET_OS_OSX +@synthesize css_clipPath = _css_clipPath; +#endif + +#pragma mark - init + +- (instancetype)init { + if (self = [super init]) { +#if TARGET_OS_OSX // [macOS] + self.textContainerInset = NSZeroSize; + self.editable = NO; + self.selectable = YES; + [self setDrawsBackground:NO]; + [self setFocusRingType:NSFocusRingTypeNone]; +#else // [macOS] + self.editable = NO; + self.selectable = YES; + self.scrollEnabled = NO; + self.textContainerInset = UIEdgeInsetsZero; + self.dataDetectorTypes = UIDataDetectorTypeNone; +#endif // [macOS] + self.textContainer.lineFragmentPadding = 0; + self.backgroundColor = [UIColor clearColor]; + } + return self; +} + +#pragma mark - KuiklyRenderViewExportProtocol + +- (void)hrv_setPropWithKey:(NSString *)propKey propValue:(id)propValue { + KUIKLY_SET_CSS_COMMON_PROP; +} + +- (void)hrv_callWithMethod:(NSString *)method params:(NSString *)params callback:(KuiklyRenderCallback)callback { + KUIKLY_CALL_CSS_METHOD; +} + +- (void)hrv_removeFromSuperview { +#if !TARGET_OS_OSX + // A non-editable UITextView can remain first responder while its system + // edit menu is visible. End that native selection session before Kuikly + // detaches the view; otherwise the menu can outlive a dismissed modal. + self.selectedTextRange = nil; + [self resignFirstResponder]; +#endif + [super hrv_removeFromSuperview]; +} + +#pragma mark - setter (css property) + +// View capability: this surface's accessibility truth comes from the system +// UITextView selection semantics. The compose semantics bridge derives its +// clickable/long-clickable mask from compose click semantics (absent here); +// applying it would overwrite the text view's native traits. Decline only +// this mask — every other accessibility prop applies normally. +- (void)setCss_accessibilityInfo:(NSString *)css_accessibilityInfo { + // Intentionally ignored. +} + +- (void)setCss_text:(NSString *)css_text { + if (_css_text != css_text) { + _css_text = css_text; + [self p_applyContent]; + } +} + +- (void)setCss_fontSize:(NSNumber *)css_fontSize { + if (_css_fontSize != css_fontSize) { + _css_fontSize = css_fontSize; + [self p_applyContent]; + } +} + +- (void)setCss_fontWeight:(NSString *)css_fontWeight { + if (_css_fontWeight != css_fontWeight) { + _css_fontWeight = css_fontWeight; + [self p_applyContent]; + } +} + +- (void)setCss_color:(NSString *)css_color { + if (_css_color != css_color) { + _css_color = css_color; + [self p_applyContent]; + } +} + +- (void)setCss_lineHeight:(NSNumber *)css_lineHeight { + if (_css_lineHeight != css_lineHeight) { + _css_lineHeight = css_lineHeight; + [self p_applyContent]; + } +} + +- (void)setCss_textAlign:(NSString *)css_textAlign { + if (_css_textAlign != css_textAlign) { + _css_textAlign = css_textAlign; + [self p_applyContent]; + } +} + +#pragma mark - private + +- (UIFont *)p_font { + return [KRConvertUtil UIFont:@{ + @"fontSize": _css_fontSize ?: @(15), + @"fontWeight": _css_fontWeight ?: @"400" + }]; +} + +- (void)p_applyContent { + NSString *content = _css_text ?: @""; + UIFont *font = [self p_font]; + UIColor *textColor = _css_color ? [UIView css_color:_css_color] : [UIColor blackColor]; + + NSMutableDictionary *attributes = [NSMutableDictionary dictionary]; + attributes[NSFontAttributeName] = font; + attributes[NSForegroundColorAttributeName] = textColor; + + NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.alignment = [KRConvertUtil NSTextAlignment:_css_textAlign]; + if (_css_lineHeight.floatValue > FLT_EPSILON) { + paragraphStyle.minimumLineHeight = [_css_lineHeight floatValue]; + paragraphStyle.maximumLineHeight = [_css_lineHeight floatValue]; + CGFloat baselineOffset = ([_css_lineHeight floatValue] - font.pointSize) / 2; + attributes[NSBaselineOffsetAttributeName] = @(baselineOffset); + } + attributes[NSParagraphStyleAttributeName] = paragraphStyle; + + self.attributedText = [[NSAttributedString alloc] initWithString:content attributes:attributes]; +} + +@end diff --git a/core-render-ios/Extension/Components/KRTextAreaView.m b/core-render-ios/Extension/Components/KRTextAreaView.m index 87d967e87..ad8f5bce0 100644 --- a/core-render-ios/Extension/Components/KRTextAreaView.m +++ b/core-render-ios/Extension/Components/KRTextAreaView.m @@ -16,13 +16,20 @@ #import "KRTextAreaView.h" #import "KRComponentDefine.h" #import "KRConvertUtil.h" +#import "KRLogModule.h" #import "KRRichTextView.h" +#import "KRTextInputEventSequencer.h" #import "KuiklyRenderBridge.h" +#import "KuiklyRenderView.h" #import "NSObject+KR.h" // 字典key常量 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; /* * @brief 暴露给Kotlin侧调用的多行输入框组件 @@ -38,6 +45,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); @@ -88,6 +97,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; @@ -95,14 +106,26 @@ @interface KRTextAreaView() - (BOOL)p_shouldReapplyTextPostProcessorForIncomingRawText:(NSString *)rawText; - (BOOL)p_containsShortcodeToken:(NSString *)rawText; - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; +- (NSDictionary *)p_currentTextInputStatePayload; +- (void)p_notifyTextChangeCallbacksWithState:(NSDictionary *)state; +- (void)p_recordImeTextChangeWithMarkedText:(BOOL)hasMarkedText state:(NSDictionary *)state; +#if !TARGET_OS_OSX +- (BOOL)p_shouldForwardHardwareTabKey; +- (void)p_forwardHardwareTabKeyWithShiftPressed:(BOOL)shiftPressed; +#endif +- (void)p_updateFont; @end @implementation KRTextAreaView { NSString *_text; BOOL _didAddKeyboardNotification; + NSNumber *_pendingFocusRequestId; + NSNumber *_pendingBlurRequestId; + NSUInteger _focusRequestEpoch; NSMutableDictionary *_props; BOOL _ignoreTextDidChanged; + KRTextInputEventSequencer *_textInputEventSequencer; /** 显式设置的光标颜色 */ UIColor *_cursorColor; /** 显式设置的选中高亮颜色 */ @@ -120,6 +143,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 @@ -138,6 +162,7 @@ - (instancetype)init { self.textContainer.lineFragmentPadding = 0; self.backgroundColor = [UIColor clearColor]; _props = [NSMutableDictionary new]; + _textInputEventSequencer = [KRTextInputEventSequencer new]; } return self; } @@ -177,6 +202,7 @@ - (void)setCss_text:(NSString *)css_text { NSString *lastText = self.text ?: @""; NSString *newText = css_text ?: @""; if (![lastText isEqualToString:newText]) { + [_textInputEventSequencer invalidatePendingMarkedText]; self.text = css_text; [self textViewDidChange:self]; [self updateLineHeightIfApplicable]; @@ -222,6 +248,7 @@ - (void)setCss_enablesReturnKeyAutomatically:(NSNumber *)flag{ - (void)setCss_values:(NSString *)css_values { if (_css_values != css_values) { + [_textInputEventSequencer invalidatePendingMarkedText]; _css_values = css_values; if (_css_values.length) { KRRichTextShadow *textShadow = [KRRichTextShadow new]; @@ -283,14 +310,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"}]; - [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 p_updateFont]; } - (void)setCss_placeholder:(NSString *)css_placeholder { @@ -308,7 +338,16 @@ - (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"]; + self.secureTextEntry = isPassword; + if (isEmail || isPassword) { + self.autocapitalizationType = UITextAutocapitalizationTypeNone; + self.autocorrectionType = UITextAutocorrectionTypeNo; + self.spellCheckingType = UITextSpellCheckingTypeNo; + } } - (void)setCss_returnKeyType:(NSString *)css_returnKeyType { @@ -332,13 +371,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 { @@ -369,6 +435,7 @@ - (void)css_setTextInputState:(NSDictionary *)args { NSError *error = nil; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (!json) return; + [_textInputEventSequencer invalidatePendingMarkedText]; NSString *requestedRawText = json[@"text"] ?: @""; NSInteger requestedSelectionStart = json[@"selectionStart"] ? [json[@"selectionStart"] integerValue] : requestedRawText.length; @@ -378,16 +445,7 @@ - (void)css_setTextInputState:(NSDictionary *)args { self.css_textLengthBeyondLimit(@{}); } if (self.css_textInputStateChange) { - NSString *outputText = [self p_outputText]; - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - self.css_textInputStateChange(@{ - @"text": outputText ?: @"", - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1), - @"length": @([self p_calculateLengthForText:outputText]) - }); + self.css_textInputStateChange([self p_currentTextInputStatePayload]); } return; } @@ -402,9 +460,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]; @@ -437,15 +494,21 @@ - (void)css_setTextInputState:(NSDictionary *)args { // 触发 textInputStateChange 回调,通知长度变化 if (self.css_textInputStateChange) { - NSString *outputText = [self p_outputText]; - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - self.css_textInputStateChange(@{ - @"text": outputText ?: @"", - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1), - @"length": @([self p_calculateLengthForText:outputText]) + self.css_textInputStateChange([self p_currentTextInputStatePayload]); + } + 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 + }); }); } } @@ -453,16 +516,7 @@ - (void)css_setTextInputState:(NSDictionary *)args { - (void)css_getTextInputState:(NSDictionary *)args { KuiklyRenderCallback callback = args[KRC_CALLBACK_KEY]; if (callback) { - NSString *rawText = [self p_outputText]; - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - callback(@{ - @"text": rawText ?: @"", - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1), - @"length": @([self p_calculateLengthForText:rawText]) - }); + callback([self p_currentTextInputStatePayload]); } } @@ -476,6 +530,7 @@ - (void)updateCursorIndex:(NSUInteger)index { - (void)css_setText:(NSDictionary *)args { NSString *text = args[KRC_PARAM_KEY]; + [_textInputEventSequencer invalidatePendingMarkedText]; self.text = text; [self textViewDidChange:self]; } @@ -544,6 +599,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; @@ -681,12 +791,14 @@ - (void)textViewDidChange:(UITextView *)textView { // 文本值变化 [self p_updatePlaceholder]; // 如果有拼音输入,根据配置决定是否触发回调 if (textView.markedTextRange) { + NSDictionary *state = [self p_currentTextInputStatePayload]; + [self p_recordImeTextChangeWithMarkedText:YES state:state]; BOOL enablePinyinCallback = [self.css_enablePinyinCallback boolValue]; if (enablePinyinCallback) { - if (self.css_textDidChange) { - NSString *text = [self p_outputText].copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); - } + // Complete editing state must lead the legacy text callback. Compose then preserves + // the marked range and pairs/drops the legacy echo instead of hoisting a null + // composition back into the controlled TextFieldValue. + [self p_notifyTextChangeCallbacksWithState:state]; } return; } @@ -694,23 +806,15 @@ - (void)textViewDidChange:(UITextView *)textView { // 文本值变化 // 实时应用 textPostProcessor(emoji attachment) [self p_applyTextPostProcessorIfNeed]; - if (self.css_textDidChange) { - NSString *text = [self p_outputText].copy ?: @""; - self.css_textDidChange(@{@"text": text, @"length": @([self p_calculateLengthForText:text])}); - } - - if (self.css_textInputStateChange) { - NSString *rawText = [self p_outputText]; - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - self.css_textInputStateChange(@{ - @"text": rawText ?: @"", - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1), - @"length": @([self p_calculateLengthForText:rawText]) - }); - } + NSDictionary *state = [self p_currentTextInputStatePayload]; + [self p_recordImeTextChangeWithMarkedText:NO state:state]; + // Keep the same complete-before-legacy ordering used while marked text is active. A Chinese + // IME candidate commit can shrink the backing store dramatically (for example 31 -> 3). If + // the legacy callback leads, Compose briefly observes selection=0/composition=null before the + // authoritative native selection arrives. Publishing one frozen native snapshot in the + // complete callback first lets the callback arbiter consume the following legacy echo without + // exposing that invalid intermediate editing state or feeding it back to UIKit. + [self p_notifyTextChangeCallbacksWithState:state]; } - (void)textViewDidChangeSelection:(UITextView *)textView { @@ -720,15 +824,7 @@ - (void)textViewDidChangeSelection:(UITextView *)textView { if (!self.css_selectionChange) { return; } - NSString *rawText = [self p_outputText]; - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - self.css_selectionChange(@{ - @"text": rawText ?: @"", - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1) - }); + self.css_selectionChange([self p_currentTextInputStatePayload]); } - (void)copy:(id)sender { @@ -813,15 +909,7 @@ - (void)paste:(id)sender { self.css_textDidChange(@{@"text": newRawText, @"length": @([self p_calculateLengthForText:newRawText])}); } if (self.css_textInputStateChange) { - NSRange outputSelectionRange = [self p_getOutputSelectionRange]; - self.css_textInputStateChange(@{ - @"text": newRawText, - @"selectionStart": @(outputSelectionRange.location), - @"selectionEnd": @(NSMaxRange(outputSelectionRange)), - @"compositionStart": @(-1), - @"compositionEnd": @(-1), - @"length": @([self p_calculateLengthForText:newRawText]) - }); + self.css_textInputStateChange([self p_currentTextInputStatePayload]); } [self scrollRangeToVisible:self.selectedRange]; } @@ -830,6 +918,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 @@ -918,16 +1012,29 @@ - (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{ // 失焦 + [_textInputEventSequencer invalidatePendingMarkedText]; + _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 @@ -983,6 +1090,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 { @@ -1402,6 +1524,60 @@ - (NSRange)p_getOutputSelectionRange { return [self p_getOutputRangeWithInputRange:self.selectedRange]; } +- (NSDictionary *)p_currentTextInputStatePayload { + NSString *rawText = [self p_outputText] ?: @""; + NSRange outputSelectionRange = [self p_getOutputSelectionRange]; + NSInteger compositionStart = -1; + NSInteger compositionEnd = -1; + UITextRange *markedRange = self.markedTextRange; + if (markedRange) { + NSInteger inputStart = [self offsetFromPosition:self.beginningOfDocument toPosition:markedRange.start]; + NSInteger inputEnd = [self offsetFromPosition:self.beginningOfDocument toPosition:markedRange.end]; + if (inputStart >= 0 && inputEnd >= inputStart) { + NSRange outputMarkedRange = + [self p_getOutputRangeWithInputRange:NSMakeRange((NSUInteger)inputStart, (NSUInteger)(inputEnd - inputStart))]; + compositionStart = (NSInteger)outputMarkedRange.location; + compositionEnd = (NSInteger)NSMaxRange(outputMarkedRange); + } + } + return @{ + @"text": rawText, + @"selectionStart": @(outputSelectionRange.location), + @"selectionEnd": @(NSMaxRange(outputSelectionRange)), + @"compositionStart": @(compositionStart), + @"compositionEnd": @(compositionEnd), + @"length": @([self p_calculateLengthForText:rawText]) + }; +} + +- (void)p_notifyTextChangeCallbacksWithState:(NSDictionary *)state { + [_textInputEventSequencer notifyState:state + completeCallback:self.css_textInputStateChange + legacyCallback:self.css_textDidChange]; +} + +- (void)p_recordImeTextChangeWithMarkedText:(BOOL)hasMarkedText state:(NSDictionary *)state { + NSString *rawText = state[@"text"] ?: @""; + NSDictionary *diagnostic = [_textInputEventSequencer recordRawTextLength:rawText.length + hasMarkedText:hasMarkedText]; + if (!diagnostic) { + return; + } + + // Keep this diagnostic sparse and content-free. The sequencer only returns metadata for a + // large contraction inside one uninterrupted native edit session; blur or controlled text + // mutation invalidates the pending marked observation. + [KRLogModule logInfo:[NSString stringWithFormat: + @"[KRTextAreaView] ime_commit_large_shrink generation=%lu markedLength=%lu committedLength=%lu storageLength=%lu selectionStart=%lu selectionEnd=%lu firstResponder=%@", + (unsigned long)[diagnostic[@"generation"] unsignedIntegerValue], + (unsigned long)[diagnostic[@"markedLength"] unsignedIntegerValue], + (unsigned long)[diagnostic[@"committedLength"] unsignedIntegerValue], + (unsigned long)self.textStorage.length, + (unsigned long)[state[@"selectionStart"] unsignedIntegerValue], + (unsigned long)[state[@"selectionEnd"] unsignedIntegerValue], + self.isFirstResponder ? @"true" : @"false"]]; +} + - (NSRange)p_getOutputRangeWithInputRange:(NSRange)inputRange { if (inputRange.location == NSNotFound) { return NSMakeRange(0, 0); diff --git a/core-render-ios/Extension/Components/KRTextFieldView.m b/core-render-ios/Extension/Components/KRTextFieldView.m index 56897a36d..5f657a4ec 100644 --- a/core-render-ios/Extension/Components/KRTextFieldView.m +++ b/core-render-ios/Extension/Components/KRTextFieldView.m @@ -21,6 +21,8 @@ // 字典key常量 NSString *const KRVFontSizeKey = @"fontSize"; NSString *const KRVFontWeightKey = @"fontWeight"; +NSString *const KRVFontFamilyKey = @"fontFamily"; +NSString *const KRVFontContextParamKey = @"contextParam"; /* * @brief 暴露给Kotlin侧调用的多行输入框组件 @@ -34,6 +36,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 */ @@ -79,9 +83,11 @@ @interface KRTextFieldView() - (BOOL)p_containsShortcodeToken:(NSString *)rawText; - (BOOL)p_shouldRejectProgrammaticShortcodeInput:(NSString *)rawText; +- (void)p_updateFont; @end + @implementation KRTextFieldView { /** text */ NSString *_text; @@ -91,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 */ @@ -227,13 +236,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"}]; + [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 p_updateFont]; } - (void)setCss_placeholder:(NSString *)css_placeholder { @@ -248,7 +261,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 { @@ -276,13 +296,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 { @@ -477,15 +525,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 { @@ -570,6 +630,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 ; @@ -914,5 +988,3 @@ - (NSUInteger)p_calculateCharacterLengthForAttributedText:(NSAttributedString *) } @end - - diff --git a/core-render-ios/Extension/Components/KRTextInputEventSequencer.h b/core-render-ios/Extension/Components/KRTextInputEventSequencer.h new file mode 100644 index 000000000..33a21f7b9 --- /dev/null +++ b/core-render-ios/Extension/Components/KRTextInputEventSequencer.h @@ -0,0 +1,34 @@ +// +// KRTextInputEventSequencer.h +// Kuikly +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^KRTextInputStateCallback)(NSDictionary *state); + +/// Owns the ordering and edit-session lifetime of native text-input callback metadata. +/// This class intentionally has no UIKit dependency so its production behavior can be executed +/// by a small host-side fixture in CI. +@interface KRTextInputEventSequencer : NSObject + +@property (nonatomic, assign, readonly) NSUInteger editGeneration; + +- (void)notifyState:(NSDictionary *)state + completeCallback:(nullable KRTextInputStateCallback)completeCallback + legacyCallback:(nullable KRTextInputStateCallback)legacyCallback; + +/// Returns content-free metadata only for a large marked -> committed contraction in the same +/// uninterrupted native edit session. Ordinary changes return nil. +- (nullable NSDictionary *)recordRawTextLength:(NSUInteger)rawTextLength + hasMarkedText:(BOOL)hasMarkedText; + +/// Ends the pending marked-text observation. Call this for blur and every programmatic text-state +/// mutation so a later edit cannot be attributed to an earlier IME session. +- (void)invalidatePendingMarkedText; + +@end + +NS_ASSUME_NONNULL_END diff --git a/core-render-ios/Extension/Components/KRTextInputEventSequencer.m b/core-render-ios/Extension/Components/KRTextInputEventSequencer.m new file mode 100644 index 000000000..f0c785478 --- /dev/null +++ b/core-render-ios/Extension/Components/KRTextInputEventSequencer.m @@ -0,0 +1,63 @@ +// +// KRTextInputEventSequencer.m +// Kuikly +// + +#import "KRTextInputEventSequencer.h" + +@implementation KRTextInputEventSequencer { + BOOL _hasPendingMarkedText; + NSUInteger _pendingMarkedRawTextLength; + NSUInteger _editGeneration; +} + +- (NSUInteger)editGeneration { + return _editGeneration; +} + +- (void)notifyState:(NSDictionary *)state + completeCallback:(KRTextInputStateCallback)completeCallback + legacyCallback:(KRTextInputStateCallback)legacyCallback { + // Both callbacks are derived from the exact same immutable native snapshot. The complete + // state must lead so Compose can pair and consume the following legacy echo atomically. + if (completeCallback) { + completeCallback(state); + } + if (legacyCallback) { + legacyCallback(@{ + @"text": state[@"text"] ?: @"", + @"length": state[@"length"] ?: @0 + }); + } +} + +- (NSDictionary *)recordRawTextLength:(NSUInteger)rawTextLength + hasMarkedText:(BOOL)hasMarkedText { + _editGeneration += 1; + if (hasMarkedText) { + _hasPendingMarkedText = YES; + _pendingMarkedRawTextLength = rawTextLength; + return nil; + } + if (!_hasPendingMarkedText) { + return nil; + } + + NSUInteger previousLength = _pendingMarkedRawTextLength; + [self invalidatePendingMarkedText]; + if (previousLength <= rawTextLength || previousLength - rawTextLength < 8) { + return nil; + } + return @{ + @"generation": @(_editGeneration), + @"markedLength": @(previousLength), + @"committedLength": @(rawTextLength) + }; +} + +- (void)invalidatePendingMarkedText { + _hasPendingMarkedText = NO; + _pendingMarkedRawTextLength = 0; +} + +@end diff --git a/core-render-ios/Extension/Components/KRView.m b/core-render-ios/Extension/Components/KRView.m index e9a27fef6..fb172633c 100644 --- a/core-render-ios/Extension/Components/KRView.m +++ b/core-render-ios/Extension/Components/KRView.m @@ -132,63 +132,95 @@ - (void)setCss_mouseExit:(KuiklyRenderCallback)css_mouseExit { #if !TARGET_OS_OSX // [macOS] - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - [super touchesBegan:touches withEvent:event]; // 如果走compose(superTouch),由手势驱动,不由touch驱动事件 + BOOL handled = NO; if (_css_touchDown && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchDown([self p_generateBaseParamsWithEvent:event eventName:@"touchDown"]); } + if (!handled) { + [super touchesBegan:touches withEvent:event]; + } } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { - [super touchesEnded:touches withEvent:event]; + BOOL handled = NO; if (_css_touchUp && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchUp([self p_generateBaseParamsWithEvent:event eventName:@"touchUp"]); } + if (!handled) { + [super touchesEnded:touches withEvent:event]; + } } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { - [super touchesMoved:touches withEvent:event]; + BOOL handled = NO; if (_css_touchMove && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchMove([self p_generateBaseParamsWithEvent:event eventName:@"touchMove"]); } + if (!handled) { + [super touchesMoved:touches withEvent:event]; + } } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { - [super touchesCancelled:touches withEvent:event]; + BOOL handled = NO; if (_css_touchUp && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchUp([self p_generateBaseParamsWithEvent:event eventName:@"touchCancel"]); } + if (!handled) { + [super touchesCancelled:touches withEvent:event]; + } } #else - (void)touchesBeganWithEvent:(NSEvent *)event { - [super touchesBeganWithEvent:event]; // 如果走compose(superTouch),由手势驱动,不由touch驱动事件 + BOOL handled = NO; if (_css_touchDown && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchDown([self p_generateBaseParamsWithEvent:event eventName:@"touchDown"]); } + if (!handled) { + [super touchesBeganWithEvent:event]; + } } - (void)touchesEndedWithEvent:(UIEvent *)event { - [super touchesEndedWithEvent:event]; + BOOL handled = NO; if (_css_touchUp && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchUp([self p_generateBaseParamsWithEvent:event eventName:@"touchUp"]); } + if (!handled) { + [super touchesEndedWithEvent:event]; + } } - (void)touchesMovedWithEvent:(UIEvent *)event { - [super touchesMovedWithEvent:event]; + BOOL handled = NO; if (_css_touchMove && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchMove([self p_generateBaseParamsWithEvent:event eventName:@"touchMove"]); } + if (!handled) { + [super touchesMovedWithEvent:event]; + } } - (void)touchesCancelledWithEvent:(UIEvent *)event { - [super touchesCancelledWithEvent:event]; + BOOL handled = NO; if (_css_touchUp && ![self.css_superTouch boolValue]) { + handled = YES; _css_touchUp([self p_generateBaseParamsWithEvent:event eventName:@"touchCancel"]); } + if (!handled) { + [super touchesCancelledWithEvent:event]; + } } #endif // [macOS] 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/Extension/Modules/KRFileModule.m b/core-render-ios/Extension/Modules/KRFileModule.m index 9ff615ff9..284efa29b 100644 --- a/core-render-ios/Extension/Modules/KRFileModule.m +++ b/core-render-ios/Extension/Modules/KRFileModule.m @@ -7,6 +7,27 @@ #import "KRFileModule.h" #import "NSObject+KR.h" +static dispatch_queue_t KRProfilerFileQueue(void) { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.tencent.kuikly.profiler.file", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +// Accessed only from KRProfilerFileQueue(). A successful operation is recorded before its +// callback, so a retry through another Pager can safely acknowledge a lost callback without +// applying the same append or overwrite twice. +static NSMutableSet *KRCompletedProfilerOperationIds(void) { + static NSMutableSet *operationIds; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + operationIds = [[NSMutableSet alloc] init]; + }); + return operationIds; +} + @implementation KRFileModule /** @@ -41,6 +62,7 @@ - (void)appendFile:(NSDictionary *)args { NSString *filename = params[@"filename"]; NSString *content = params[@"content"]; + NSString *operationId = params[@"operationId"]; if (!filename || !content) { if (callback) callback(@{@"error": @"missing filename or content"}); @@ -50,7 +72,12 @@ - (void)appendFile:(NSDictionary *)args { NSString *profilerDir = [self profilerDir]; NSString *filePath = [profilerDir stringByAppendingPathComponent:filename]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(KRProfilerFileQueue(), ^{ + NSMutableSet *completedOperationIds = KRCompletedProfilerOperationIds(); + if (operationId.length > 0 && [completedOperationIds containsObject:operationId]) { + if (callback) callback(@{@"path": filePath}); + return; + } NSError *error = nil; // 追加写:每行内容末尾加换行,适合 JSONL 格式 NSString *line = [content stringByAppendingString:@"\n"]; @@ -62,12 +89,18 @@ - (void)appendFile:(NSDictionary *)args { [handle seekToEndOfFile]; [handle writeData:data]; [handle closeFile]; + if (operationId.length > 0) { + [completedOperationIds addObject:operationId]; + } if (callback) callback(@{@"path": filePath}); } else { if (callback) callback(@{@"error": @"failed to open file for appending"}); } } else { BOOL success = [data writeToFile:filePath options:NSDataWritingAtomic error:&error]; + if (success && operationId.length > 0) { + [completedOperationIds addObject:operationId]; + } if (callback) { if (success) { callback(@{@"path": filePath}); @@ -85,6 +118,7 @@ - (void)writeFile:(NSDictionary *)args { NSString *filename = params[@"filename"]; NSString *content = params[@"content"]; + NSString *operationId = params[@"operationId"]; if (!filename || !content) { if (callback) callback(@{@"error": @"missing filename or content"}); @@ -94,12 +128,20 @@ - (void)writeFile:(NSDictionary *)args { NSString *profilerDir = [self profilerDir]; NSString *filePath = [profilerDir stringByAppendingPathComponent:filename]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(KRProfilerFileQueue(), ^{ + NSMutableSet *completedOperationIds = KRCompletedProfilerOperationIds(); + if (operationId.length > 0 && [completedOperationIds containsObject:operationId]) { + if (callback) callback(@{@"path": filePath}); + return; + } NSError *error = nil; BOOL success = [content writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:&error]; + if (success && operationId.length > 0) { + [completedOperationIds addObject:operationId]; + } if (callback) { if (success) { callback(@{@"path": filePath}); 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 8f5970d49..ca6cc5514 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.h +++ b/core-render-ios/Extension/Vendor/KRLabel.h @@ -23,6 +23,18 @@ 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; +extern NSString *const KRInlineBoxStyleAttributeName; +extern NSString *const KRInlineBoxSemanticAttributeName; + +@protocol KRSlockInlineCodeAtomProtocol +- (BOOL)kr_slockInlineCodeLeadingEdge; +- (BOOL)kr_slockInlineCodeTrailingEdge; +@end @interface KRLabel : UILabel @@ -137,6 +149,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 31bda84f7..611f46d77 100644 --- a/core-render-ios/Extension/Vendor/KRLabel.m +++ b/core-render-ios/Extension/Vendor/KRLabel.m @@ -19,10 +19,81 @@ #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"; NSString *const KRBGAttributeKey = @"KRBGAttributeKey"; +NSString *const KRSlockChromeAttributeName = @"KRSlockChromeAttributeName"; +NSString *const KRInlineBoxStyleAttributeName = @"KRInlineBoxStyleAttributeName"; +NSString *const KRInlineBoxSemanticAttributeName = @"KRInlineBoxSemanticAttributeName"; + +static const uint32_t kKRSlockInlineCodeFillARGB = 0x66FFD440; // react bg-soft-signal/40 = #FFD440 @ 40% (was 0x66FFD84D, the Android outlier — SlockMarkdown.kt:1485-90) +static const CGFloat kKRSlockBorderWidthPt = 1.0; // 1dp black border (border-black) + +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]; +} + +static NSString *KRRestoredAttachmentString(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; +} + +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() @@ -53,7 +124,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; } @@ -523,8 +594,195 @@ @implementation KRLayoutManager{ - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { _drawAtPoint = origin; [super drawBackgroundForGlyphRange:glyphsToShow atPoint:origin]; + [self kr_drawInlineBoxChromeForGlyphRange:glyphsToShow atPoint:origin]; + [self kr_drawSlockInlineCodeChromeForGlyphRange:glyphsToShow atPoint:origin]; _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]; + CGFloat left = CGRectGetMinX(bounds) + origin.x; + CGFloat right = CGRectGetMaxX(bounds) + origin.x; + if (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) { + CGPoint leadingLocation = [self locationForGlyphAtIndex:leadingGlyphRange.location]; + CGPoint trailingLocation = [self locationForGlyphAtIndex:trailingGlyphRange.location]; + 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; + CGFloat boxHeight = [style[@"boxHeight"] doubleValue]; + if (boxHeight <= 0) { + boxHeight = CGRectGetHeight(bounds) + paddingTop + paddingBottom + borderWidth * 2.0; + } + CGFloat fragmentTop = CGRectGetMinY(lineRect) + origin.y; + CGFloat fragmentBottom = CGRectGetMaxY(lineRect) + origin.y; + 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"]; + 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); + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect cornerRadius:MAX(0, radius - borderWidth / 2.0)]; + [path stroke]; + } + }]; + }]; +} + +- (void)kr_drawSlockInlineCodeChromeForGlyphRange:(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 isEqualToString:@"inlineCode"]) { + return; + } + UIColor *fillColor = KRSlockInlineCodeFillColor(); + NSRange runGlyphRange = [self glyphRangeForCharacterRange:runRange actualCharacterRange:NULL]; + if (runGlyphRange.length == 0) { + return; + } + NSUInteger runGlyphEnd = NSMaxRange(runGlyphRange); + [self enumerateLineFragmentsForGlyphRange:runGlyphRange + usingBlock:^(CGRect lineRect, CGRect usedRect, NSTextContainer *lineContainer, NSRange lineGlyphRange, BOOL *lineStop) { + NSRange segmentGlyphRange = NSIntersectionRange(lineGlyphRange, runGlyphRange); + if (segmentGlyphRange.length == 0) { + return; + } + 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; + CGFloat textSize = font ? font.pointSize : 15.0; + BOOL isRunStart = (segmentGlyphRange.location == runGlyphRange.location); + BOOL isRunEnd = (NSMaxRange(segmentGlyphRange) >= runGlyphEnd); + if (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 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 = CGRectGetMinY(gb) + origin.y; + CGFloat bottom = CGRectGetMaxY(gb) + origin.y; + if (bottom <= top) { + return; + } + CGContextSetFillColorWithColor(ctx, fillColor.CGColor); + CGContextFillRect(ctx, CGRectMake(left, top, right - left, bottom - top)); + 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)); + if (isRunStart) { + CGContextFillRect(ctx, CGRectMake(bl, bt, bw, bb - bt)); + } + if (isRunEnd) { + CGContextFillRect(ctx, CGRectMake(br - bw, bt, bw, bb - bt)); + } + }]; + }]; +} - (void)dealloc{ #if DEBUG @@ -673,5 +931,3 @@ - (void)setHr_size:(CGSize)hr_size{ objc_setAssociatedObject(self, @selector(hr_size), [NSValue valueWithCGSize:hr_size], OBJC_ASSOCIATION_RETAIN); } @end - - 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/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-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-ios/Thread/KuiklyRenderThreadBridge.h b/core-render-ios/Thread/KuiklyRenderThreadBridge.h index 93147d0e8..02e3bc619 100644 --- a/core-render-ios/Thread/KuiklyRenderThreadBridge.h +++ b/core-render-ios/Thread/KuiklyRenderThreadBridge.h @@ -24,6 +24,9 @@ extern "C" { /// The provided callback will be invoked on the context queue with the same pagerId C string. FOUNDATION_EXPORT void com_tencent_kuikly_ScheduleContextTask(const char * _Nullable pagerId, void (* _Nullable onSchedule)(const char * _Nullable pagerId)); +/// C-callable: Schedule one bounded task after normal Kuikly context work drains. +FOUNDATION_EXPORT void com_tencent_kuikly_ScheduleContextIdleTask(const char * _Nullable pagerId, void (* _Nullable onSchedule)(const char * _Nullable pagerId)); + /// C-callable: Return true if the current thread is the context thread. /// `pagerId` parameter currently ignored but kept for API compatibility. FOUNDATION_EXPORT bool com_tencent_kuikly_IsCurrentOnContextThread(const char * _Nullable pagerId); @@ -31,4 +34,3 @@ FOUNDATION_EXPORT bool com_tencent_kuikly_IsCurrentOnContextThread(const char * #ifdef __cplusplus } #endif - diff --git a/core-render-ios/Thread/KuiklyRenderThreadBridge.m b/core-render-ios/Thread/KuiklyRenderThreadBridge.m index 6d64d9116..11bd4a931 100644 --- a/core-render-ios/Thread/KuiklyRenderThreadBridge.m +++ b/core-render-ios/Thread/KuiklyRenderThreadBridge.m @@ -41,8 +41,26 @@ void com_tencent_kuikly_ScheduleContextTask(const char* pagerId, void (*onSchedu [KuiklyRenderThreadManager performOnContextQueueWithBlock:block sync:NO]; } +void com_tencent_kuikly_ScheduleContextIdleTask(const char* pagerId, void (*onSchedule)(const char* pagerId)) { + if (!onSchedule) return; + const char *copied = NULL; + if (pagerId) { + size_t len = strlen(pagerId) + 1; + char *buf = (char *)malloc(len); + if (buf) { + memcpy(buf, pagerId, len); + copied = buf; + } + } + + dispatch_block_t block = ^{ + onSchedule(copied); + if (copied) free((void *)copied); + }; + [KuiklyRenderThreadManager performOnContextQueueWhenIdleWithBlock:block]; +} + bool com_tencent_kuikly_IsCurrentOnContextThread(const char* pagerId) { // pagerId currently unused; keep for API compatibility return [KuiklyRenderThreadManager isContextQueue]; } - diff --git a/core-render-ios/Thread/KuiklyRenderThreadManager.h b/core-render-ios/Thread/KuiklyRenderThreadManager.h index 3d98b93d1..ae457513b 100644 --- a/core-render-ios/Thread/KuiklyRenderThreadManager.h +++ b/core-render-ios/Thread/KuiklyRenderThreadManager.h @@ -33,6 +33,14 @@ NS_ASSUME_NONNULL_BEGIN * @param sync 是否同步执行 */ + (void)performOnContextQueueWithBlock:(dispatch_block_t)block sync:(BOOL)sync; + +/* + * Runs one bounded speculative block after the context queue has remained + * free of newly enqueued normal work until the admission marker executes. + * The block runs with utility QoS and is rescheduled behind foreground work + * when the normal-work generation changes. + */ ++ (void)performOnContextQueueWhenIdleWithBlock:(dispatch_block_t)block; /* * 如果是在context线程的话,立即在context线程执行,否则next runloop执行 */ diff --git a/core-render-ios/Thread/KuiklyRenderThreadManager.m b/core-render-ios/Thread/KuiklyRenderThreadManager.m index a189c866e..89e3191c4 100644 --- a/core-render-ios/Thread/KuiklyRenderThreadManager.m +++ b/core-render-ios/Thread/KuiklyRenderThreadManager.m @@ -15,11 +15,17 @@ #import "KuiklyRenderThreadManager.h" #import "KRLogModule.h" +#import +#import +#include +#include NSString *const KRRenderContextQueueName = @"com.tencent.kuikly.context"; NSString *const KRRenderLogQueueName = @"com.tencent.kuikly.log"; @implementation KuiklyRenderThreadManager +static _Atomic(uint64_t) gContextNormalGeneration = 0; + // 指定Context线程执行闭包 + (void)performOnContextQueueWithBlock:(dispatch_block_t)block { [self performOnContextQueueWithBlock:block sync:NO]; @@ -27,6 +33,7 @@ + (void)performOnContextQueueWithBlock:(dispatch_block_t)block { // 指定Context线程执行闭包 + (void)performOnContextQueueWithBlock:(dispatch_block_t)block sync:(BOOL)sync { + atomic_fetch_add_explicit(&gContextNormalGeneration, 1, memory_order_relaxed); if (sync) { if ([self isContextQueue]) { block(); @@ -38,15 +45,64 @@ + (void)performOnContextQueueWithBlock:(dispatch_block_t)block sync:(BOOL)sync { } } ++ (void)performOnContextQueueWhenIdleWithBlock:(dispatch_block_t)block { + if (!block) { + return; + } + uint64_t generation = atomic_load_explicit(&gContextNormalGeneration, memory_order_relaxed); + dispatch_async([KuiklyRenderThreadManager contextQueue], ^{ + if (atomic_load_explicit(&gContextNormalGeneration, memory_order_relaxed) != generation) { + [KuiklyRenderThreadManager performOnContextQueueWhenIdleWithBlock:block]; + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler( + kCFAllocatorDefault, + kCFRunLoopBeforeWaiting, + false, + INT_MAX, + ^(CFRunLoopObserverRef observerRef, CFRunLoopActivity activity) { + dispatch_async([KuiklyRenderThreadManager contextQueue], ^{ + if (atomic_load_explicit(&gContextNormalGeneration, memory_order_relaxed) != generation) { + [KuiklyRenderThreadManager performOnContextQueueWhenIdleWithBlock:block]; + return; + } + if (atomic_load_explicit(&gContextNormalGeneration, memory_order_relaxed) != generation) { + [KuiklyRenderThreadManager performOnContextQueueWhenIdleWithBlock:block]; + return; + } + // A QoS-bearing dispatch block still inherits this queue's + // user-interactive class. Lower the context worker itself for + // the bounded idle callback, then restore it even on exception. + qos_class_t previousQoS = qos_class_self(); + int qosResult = pthread_set_qos_class_self_np(QOS_CLASS_UTILITY, 0); + @try { + block(); + } @finally { + if (qosResult == 0) { + pthread_set_qos_class_self_np(previousQoS, 0); + } + } + }); + } + ); + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); + CFRelease(observer); + CFRunLoopWakeUp(CFRunLoopGetMain()); + }); + }); +} + + (void)performOnLogQueueWithBlock:(dispatch_block_t)block { dispatch_async([KuiklyRenderThreadManager logQueue], block); } + (void)performOnContextQueueImmediatelyWithBlock:(dispatch_block_t)block { if ([self isContextQueue]) { + atomic_fetch_add_explicit(&gContextNormalGeneration, 1, memory_order_relaxed); block(); } else { - dispatch_async([KuiklyRenderThreadManager contextQueue], block); + [self performOnContextQueueWithBlock:block sync:NO]; } } 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-ios/include/KRSelectableTextView.h b/core-render-ios/include/KRSelectableTextView.h new file mode 120000 index 000000000..09597b1d2 --- /dev/null +++ b/core-render-ios/include/KRSelectableTextView.h @@ -0,0 +1 @@ +../Extension/Components/KRSelectableTextView.h \ No newline at end of file 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/CMakeLists.txt b/core-render-ohos/src/main/cpp/CMakeLists.txt index 93f7c0ef7..1328b00fe 100644 --- a/core-render-ohos/src/main/cpp/CMakeLists.txt +++ b/core-render-ohos/src/main/cpp/CMakeLists.txt @@ -58,6 +58,7 @@ set(SOURCE_SET libohos_render/expand/components/richtext/KRCustomEmojiPixmapCache.cpp libohos_render/expand/components/scroller/KRScrollerView.cpp libohos_render/expand/components/richtext/KRRichTextView.cpp + libohos_render/expand/components/richtext/KRSelectableTextView.cpp libohos_render/expand/components/richtext/KRParagraph.cpp libohos_render/utils/KRLinearGradientParser.cpp libohos_render/expand/components/richtext/gradient_richtext/KRGradientRichTextShadow.cpp 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/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 59209e9f3..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 @@ -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; @@ -63,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, @@ -86,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 335eb77e7..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 @@ -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_; @@ -82,29 +84,55 @@ 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 handler = context_handler_map_.Get(instanceId); - if (!handler || nullptr == KRRenderManager::GetInstance().GetRenderView(instanceId)) { - auto cv = KRRenderCValue(); - cv.type = KRRenderCValue::NULL_VALUE; - return cv; + // 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)) { + 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); + }); + return KRRenderCValue{}; } - 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 return_value = - handler->OnCallNative(static_cast(methodId), 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; + auto return_value = DispatchPreparedCallNative(instanceId, method, cv0, cv1, cv2, cv3, cv4, cv5); + 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(); } + +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..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); } @@ -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..9604b6b42 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_; @@ -54,6 +50,11 @@ void com_tencent_kuikly_ScheduleContextTask(const char *pagerId, void (*onSchedu 0, [instanceId = std::string(pagerId), onSchedule]() { onSchedule(instanceId.c_str()); }); } +void com_tencent_kuikly_ScheduleContextIdleTask(const char *pagerId, void (*onSchedule)(const char *pagerId)) { + KRContextScheduler::ScheduleIdleTask( + [instanceId = std::string(pagerId), onSchedule]() { onSchedule(instanceId.c_str()); }); +} + bool com_tencent_kuikly_IsCurrentOnContextThread(const char *pagerId) { return KRContextScheduler::IsCurrentOnContextThread(); } @@ -232,18 +233,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, @@ -338,23 +328,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); @@ -446,4 +439,4 @@ void KRRenderCore::notifyInitState(KRInitState state) { derivedPtr->DispatchInitState(state); // 向根View通知初始化事件 } } -} \ No newline at end of file +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/ComponentsRegisterEntry.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/ComponentsRegisterEntry.h index 125c744b6..020ccf8e2 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/ComponentsRegisterEntry.h +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/ComponentsRegisterEntry.h @@ -35,6 +35,7 @@ #include "libohos_render/expand/components/modal/KRModalView.h" #include "libohos_render/expand/components/richtext/KRRichTextShadow.h" #include "libohos_render/expand/components/richtext/KRRichTextView.h" +#include "libohos_render/expand/components/richtext/KRSelectableTextView.h" #include "libohos_render/expand/components/richtext/gradient_richtext/KRGradientRichTextShadow.h" #include "libohos_render/expand/components/richtext/gradient_richtext/KRGradientRichTextView.h" #include "libohos_render/expand/components/scroller/KRScrollerView.h" @@ -58,6 +59,9 @@ static void ComponentsRegisterEntry() { IKRRenderViewExport::RegisterViewCreator("KRRichTextView", [] { return std::make_shared(); }); + IKRRenderViewExport::RegisterViewCreator("KRSelectableTextView", + [] { return std::make_shared(); }); + IKRRenderShadowExport::RegisterShadowCreator("KRRichTextView", [] { return std::make_shared(); }); diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/canvas/KRCanvasView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/canvas/KRCanvasView.cpp index caea1f41d..1bde9ae66 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/components/canvas/KRCanvasView.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/canvas/KRCanvasView.cpp @@ -594,6 +594,10 @@ void KRCanvasView::DrawImage(const std::string ¶ms) { auto obj = kuikly::util::JSONObject::Parse(params); std::string cacheKey = obj->GetString("cacheKey"); auto module = std::dynamic_pointer_cast(GetModule(kMemoryCacheModuleName)); + if (!module) { + KR_LOG_ERROR_WITH_TAG("KRCanvasView") << "memory cache module unavailable while drawing image"; + return; + } auto pixelmap = module->GetImage(cacheKey); if (!pixelmap) { return; @@ -620,11 +624,16 @@ void KRCanvasView::DrawImage(const std::string ¶ms) { float dHeight = obj->GetNumber("dHeight", sHeight); OH_Drawing_PixelMap *drawingPixelMap = OH_Drawing_PixelMapGetFromOhPixelMapNative(pixelmap); + if (!drawingPixelMap) { + KR_LOG_ERROR_WITH_TAG("KRCanvasView") << "failed to convert validated PixelMap for Canvas drawing"; + return; + } OH_Drawing_Rect *srcRect = OH_Drawing_RectCreate(sx, sy, sx + sWidth, sy + sHeight); OH_Drawing_Rect *dstRect = OH_Drawing_RectCreate(dx, dy, dx + dWidth, dy + dHeight); OH_Drawing_CanvasDrawPixelMapRect(canvas_, drawingPixelMap, srcRect, dstRect, nullptr); OH_Drawing_RectDestroy(srcRect); OH_Drawing_RectDestroy(dstRect); + OH_Drawing_PixelMapDissolve(drawingPixelMap); } } 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); 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..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 @@ -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); } @@ -75,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() { @@ -90,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) { @@ -137,3 +174,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..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 @@ -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); @@ -56,13 +58,24 @@ 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; + 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/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..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 @@ -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"; @@ -65,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"; @@ -134,11 +136,14 @@ 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); // 直接限制 } -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()); @@ -148,6 +153,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; @@ -347,9 +360,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 +383,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; + } } /** @@ -407,15 +430,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。 @@ -447,25 +469,30 @@ 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)); + 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,并把光标重置到文本末尾。如果在这里同步调用 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 写回来形成回环。 + // OnTextDidChanged 的整个时窗,避免受控写入通过 complete 或 legacy 事件再次回流。 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(); @@ -480,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); @@ -489,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 回吐给业务。 */ @@ -512,7 +578,7 @@ void KRTextFieldView::GetTextInputStateInternal(const KRRenderCallback &callback } /** - * 在 OnTextDidChanged 末尾按需触发 textInputStateChange。 + * 在 OnTextDidChanged 中按需触发 textInputStateChange。 * 主动写入期间通过 is_setting_text_input_state_ 抑制,避免业务死循环。 */ void KRTextFieldView::NotifyTextInputStateChange() { @@ -522,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))); } /** @@ -538,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))); } /** @@ -555,12 +627,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(); } /** @@ -582,6 +653,13 @@ 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,导致光标跳到最前面。 + NotifyTextInputStateChange(); if (text_did_change_callback_) { auto text = GetContentText(); KRRenderValueMap map; @@ -593,30 +671,39 @@ void KRTextFieldView::OnTextDidChanged(ArkUI_NodeEvent *event) { } text_did_change_callback_(NewKRRenderValue(map)); } - // 同一时机触发 textInputStateChange(与 Android KRTextFieldView 一致)。 - // 主动写入期间由 NotifyTextInputStateChange 内部抑制,避免业务回流。 - NotifyTextInputStateChange(); } /** * 获焦回调 */ void KRTextFieldView::OnInputFocus(ArkUI_NodeEvent *event) { + pending_blur_request_id_ = 0; + ClearPendingCompleteTextInputStates(); 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; + ClearPendingCompleteTextInputStates(); 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; } /** * 按下完成键回调 @@ -625,7 +712,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)); @@ -792,7 +879,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) { @@ -829,7 +916,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 8184b44d4..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 { @@ -79,10 +80,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 void UpdateInputNodeFocusStatus(int status); + 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 节点类型。 @@ -92,7 +105,29 @@ 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: + 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; @@ -109,17 +144,20 @@ 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; /** * 输入框获焦(弹起键盘) */ - void Focus(); + void Focus(int64_t request_id = 0); /** * 输入框失焦(收起键盘) */ - void Blur(); + void Blur(int64_t request_id = 0); /** * 获取光标位置 @@ -138,13 +176,11 @@ 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 回调, - * 避免业务把状态写回来形成死循环。 + * 主动写入期间通过 is_setting_text_input_state_ 抑制 textInputStateChange + * 与 legacy textDidChange 回调,避免业务把状态写回来形成死循环。 */ void SetTextInputStateInternal(const std::string &json); @@ -155,27 +191,38 @@ 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 回吐给业务。 */ void GetTextInputStateInternal(const KRRenderCallback &callback); /** - * 在 OnTextDidChanged 末尾按需触发,参考 Android 时机一致。 + * 在 OnTextDidChanged 中按需触发,且必须早于 legacy textDidChange,与 Android 顺序一致。 * 处于 SetTextInputStateInternal 主动写入期间会被抑制。 */ void NotifyTextInputStateChange(); /** * 选区变化事件回调,跨端语义对齐 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); @@ -281,16 +328,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 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..d29c586fd 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,15 @@ 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, kuikly::util::ConvertToTextDecorationThicknessScale(textDecorationThickness * dpi, fontSize)); + } + } OH_Drawing_SetTextStyleFontStyle(txtStyle, fontStyle); if (letterSpacing > 0) { OH_Drawing_SetTextStyleLetterSpacing(txtStyle, letterSpacing * dpi); @@ -463,4 +475,4 @@ OH_Drawing_ShaderEffect *KRParagraph::CreateShaderEffect(std::shared_ptr #include +#include #include +#include #include #include @@ -65,6 +67,93 @@ 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 kInlineBoxWordJoiner = u'\u2060'; +constexpr char16_t kObjectReplacementCharacter = u'\uFFFC'; +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__"; +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); +} + +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 KRSlockInlineCodeFillColor() { + return 0x66FFD440; +} + +} // namespace + static bool isRawFilePath(const std::string &src) { return src.find(kRawFilePrefix) == 0; } @@ -107,13 +196,33 @@ 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"); } 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 +308,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 +448,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_)); @@ -416,6 +538,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); @@ -427,13 +667,54 @@ 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; - 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(); + 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) { @@ -442,6 +723,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(); @@ -449,24 +731,68 @@ 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()); 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(); 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 uint32_t slockInlineCodeFillColor = KRSlockInlineCodeFillColor(); + 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 = slockInlineCode || isInlineBox; + if (hasBoxChrome) { + textDecoration = TEXT_DECORATION_NONE; + } auto placeholderWidth = GetKRValue("placeholderWidth", spanMap, spanMap)->toDouble(); // 创建文本样式对象txtStyle 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 (!hasBoxChrome && 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); @@ -531,6 +857,15 @@ 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, kuikly::util::ConvertToTextDecorationThicknessScale(textDecorationThickness * dpi, fontSize)); + } + } OH_Drawing_SetTextStyleFontStyle(txtStyle, fontStyle); if (letterSpacing > 0) { OH_Drawing_SetTextStyleLetterSpacing(txtStyle, letterSpacing * dpi); @@ -614,7 +949,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" @@ -630,15 +970,148 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w } placeholder_count++; charOffset += 1; + 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 + // advance instead of painting a visible whitespace glyph. + OH_Drawing_PlaceholderSpan trailingMargin = { + fontSize * kSlockInlineCodeTrailingMarginRatio, + fontSize * kSlockInlineCodeLineHeightRatio, + 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 (hasBoxChrome) { + const float borderWidth = isInlineBox + ? inlineBoxBorderWidth + : std::max(1.0f, static_cast(dpi) * kSlockInlineCodeBorderWidthVp); + const float paddingStart = isInlineBox + ? inlineBoxPaddingStart + : fontSize * kSlockInlineCodeInnerPaddingRatio; + const float paddingEnd = isInlineBox + ? inlineBoxPaddingEnd + : fontSize * kSlockInlineCodeInnerPaddingRatio; + const float marginStart = isInlineBox + ? inlineBoxMarginStart + : fontSize * kSlockInlineCodeOuterMarginRatio; + const float marginEnd = isInlineBox + ? inlineBoxMarginEnd + : fontSize * kSlockInlineCodeOuterMarginRatio; + const float boxHeight = isInlineBox + ? fontSize + inlineBoxPaddingTop + inlineBoxPaddingBottom + borderWidth * 2.0f + : fontSize * kSlockInlineCodeLineHeightRatio; + 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, &leadingEdgePlaceholder); + 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, + isInlineBox + ? (inlineBoxBackgroundColorStr.length() + ? kuikly::util::ConvertToHexColor(inlineBoxBackgroundColorStr) + : 0) + : slockInlineCodeFillColor, + isInlineBox + ? (inlineBoxBorderColorStr.length() + ? kuikly::util::ConvertToHexColor(inlineBoxBorderColorStr) + : 0) + : 0xFF000000, + borderWidth, + paddingStart, + paddingEnd, + marginStart, + marginEnd, + boxHeight, + inlineBoxCornerRadius, + false, + }); + } + + OH_Drawing_TypographyHandlerAddPlaceholder(handler, &trailingEdgePlaceholder); + 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(); - span_offsets_.emplace_back(std::tuple(spanIndex, charOffset, charOffset + codePointCount)); + const std::u16string text16 = KRUtf8ToUtf16(text); + const int codePointCount = static_cast(text16.size()); + if (!isInlineBoxGroupPart) { + span_offsets_.emplace_back(std::tuple(spanIndex, charOffset, charOffset + codePointCount)); + } charOffset += codePointCount; + if (isInlineBoxGroupPart) { + append_mapped_text(text16, {}, nullptr); + } else { + append_mapped_text(text16, text16, nullptr); + } } OH_Drawing_DestroyTextStyle(txtStyle); if (textForegroundPen) { @@ -649,7 +1122,10 @@ OH_Drawing_Typography *KRRichTextShadow::BuildTextTypography(double constraint_w OH_Drawing_BrushDestroy(textForegroundBrush); textForegroundBrush = nullptr; } - spanIndex++; + if (textBackgroundBrush) { + OH_Drawing_BrushDestroy(textBackgroundBrush); + textBackgroundBrush = nullptr; + } } // 根据handler对象生成文本排版布局typography context_thread_typography_ = KRMakeTypographyHandle(OH_Drawing_CreateTypography(handler)); @@ -676,7 +1152,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_); @@ -686,7 +1162,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(); @@ -706,6 +1184,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) ===== @@ -746,7 +1228,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}; @@ -755,8 +1238,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; @@ -917,4 +1400,4 @@ void KRRichTextShadow::DestroyCachedTextLines(){ OH_Drawing_DestroyTextLines(text_lines_); text_lines_ = nullptr; } -} \ No newline at end of file +} 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..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 @@ -63,6 +63,21 @@ 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; + 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) { if (raw == nullptr) { return KRTypographyHandle(); @@ -183,9 +198,19 @@ class KRRichTextShadow : public IKRRenderShadowExport { } std::string GetTextContent() const { - return text_content_; + 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_; + } + + std::string SemanticSelection(int layout_start, int layout_end, std::string &pre, std::string &post) const; + KRSize MainMeasureSize() { return main_measure_size_; } @@ -272,7 +297,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; @@ -294,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_; @@ -323,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 cd2f1519f..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 @@ -15,6 +15,8 @@ #include "libohos_render/expand/components/richtext/KRRichTextView.h" +#include +#include #include #include #include @@ -24,6 +26,7 @@ #include #include #include +#include #include #include #include "libohos_render/expand/components/base/KRCustomUserCallback.h" @@ -33,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" { @@ -53,6 +57,141 @@ extern size_t OH_Drawing_GetEndFromRange(OH_Drawing_Range* range) __attribute__( } #endif +namespace { + +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); +} + +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; + 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; + } + OH_Drawing_Brush *brush = OH_Drawing_BrushCreate(); + OH_Drawing_BrushSetAntiAlias(brush, drawFill); + + 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 : run.border_color); + OH_Drawing_CanvasAttachBrush(canvas, brush); + 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 = 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 && 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; + } + + 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; @@ -107,13 +246,18 @@ 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 的入口。这个判定已收敛到 // 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(); @@ -127,6 +271,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); } @@ -149,6 +295,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); } @@ -158,10 +307,15 @@ void KRRichTextView::DidRemoveFromParentView() { IKRRenderViewExport::DidRemoveFromParentView(); shadow_ = nullptr; paragraph_ = nullptr; + use_styled_string_ = false; + has_explicit_accessibility_ = 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; @@ -218,6 +372,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(); @@ -254,6 +413,7 @@ void KRRichTextView::OnForegroundDraw(ArkUI_NodeCustomEvent *event) { } } if(line_count > 0){ + KRDrawSlockChipChrome(drawingHandle, textTypo, richTextShadow->SlockChromeRuns(), drawOffsetY, false); return; } } @@ -333,6 +493,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, @@ -367,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); } @@ -753,6 +924,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) { @@ -802,4 +976,4 @@ bool KRRichTextView::UpdateSelection(std::shared_ptr ancest SetSelected(has_intersection); return has_intersection; -} \ No newline at end of file +} 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 2f9d770bd..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,8 +167,10 @@ 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; float last_draw_frame_width_ = -1.0; float line_break_margin_ = 0; KRParagraphSelectionInfo selection_rects_; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.cpp new file mode 100644 index 000000000..a2212ef0a --- /dev/null +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.cpp @@ -0,0 +1,91 @@ +/* + * 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. + */ + +#include "libohos_render/expand/components/richtext/KRSelectableTextView.h" + +#include "libohos_render/utils/KRConvertUtil.h" +#include "libohos_render/utils/KRViewUtil.h" + +static constexpr const char *kSelectableTextPropText = "text"; +static constexpr const char *kSelectableTextPropFontSize = "fontSize"; +static constexpr const char *kSelectableTextPropFontWeight = "fontWeight"; +static constexpr const char *kSelectableTextPropColor = "color"; +static constexpr const char *kSelectableTextPropLineHeight = "lineHeight"; +static constexpr const char *kSelectableTextPropTextAlign = "textAlign"; + +ArkUI_NodeHandle KRSelectableTextView::CreateNode() { + return kuikly::util::GetNodeApi()->createNode(ARKUI_NODE_TEXT); +} + +void KRSelectableTextView::DidInit() { + IKRRenderViewExport::DidInit(); + // Enable the system selection/copy menu; keeps the surface read-only. + ArkUI_NumberValue copy_option = {.i32 = ARKUI_COPY_OPTIONS_LOCAL_DEVICE}; + ArkUI_AttributeItem copy_item = {©_option, 1}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_COPY_OPTION, ©_item); + UpdateFont(); +} + +bool KRSelectableTextView::SetProp(const std::string &prop_key, const KRAnyValue &prop_value, + const KRRenderCallback event_call_back) { + if (kuikly::util::isEqual(prop_key, kSelectableTextPropText)) { + auto text = prop_value->toString(); + ArkUI_AttributeItem item = {.string = text.c_str()}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_TEXT_CONTENT, &item); + return true; + } + if (kuikly::util::isEqual(prop_key, kSelectableTextPropFontSize)) { + font_size_ = prop_value->toFloat(); + UpdateFont(); + return true; + } + if (kuikly::util::isEqual(prop_key, kSelectableTextPropFontWeight)) { + float scale = 1.0; + if (auto root = GetRootView().lock()) { + scale = root->GetContext()->Config()->GetFontWeightScale(); + } + font_weight_ = kuikly::util::ConvertArkUIFontWeight(prop_value->toInt(), scale); + UpdateFont(); + return true; + } + if (kuikly::util::isEqual(prop_key, kSelectableTextPropColor)) { + kuikly::util::UpdateInputNodeColor(GetNode(), kuikly::util::ConvertToHexColor(prop_value->toString())); + return true; + } + if (kuikly::util::isEqual(prop_key, kSelectableTextPropLineHeight)) { + auto line_height = prop_value->toFloat(); + if (line_height > 0) { + kuikly::util::UpdateTextAreaNodeLineHeight(GetNode(), line_height); + } else { + kuikly::util::GetNodeApi()->resetAttribute(GetNode(), NODE_TEXT_LINE_HEIGHT); + } + return true; + } + if (kuikly::util::isEqual(prop_key, kSelectableTextPropTextAlign)) { + kuikly::util::UpdateInputNodeTextAlign(GetNode(), prop_value->toString()); + return true; + } + return IKRRenderViewExport::SetProp(prop_key, prop_value, event_call_back); +} + +void KRSelectableTextView::UpdateFont() { + ArkUI_NumberValue size_value[] = {{.f32 = font_size_}}; + ArkUI_AttributeItem size_item = {size_value, 1}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_FONT_SIZE, &size_item); + + ArkUI_NumberValue weight_value[] = {{.i32 = font_weight_}}; + ArkUI_AttributeItem weight_item = {weight_value, 1}; + kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_FONT_WEIGHT, &weight_item); +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.h new file mode 100644 index 000000000..517ceca36 --- /dev/null +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/richtext/KRSelectableTextView.h @@ -0,0 +1,50 @@ +/* + * 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_KRSELECTABLETEXTVIEW_H +#define CORE_RENDER_OHOS_KRSELECTABLETEXTVIEW_H + +#include "libohos_render/export/IKRRenderViewExport.h" + +/** + * System-selectable read-only plain text. + * + * An ArkUI Text node with the system copy option enabled + * (ARKUI_COPY_OPTIONS_LOCAL_DEVICE guarantees local-device copy scope) so + * long-press brings up the platform's native selection menu. Baseline + * guarantee: select / select-all / copy. Any additional menu items are + * whatever the OS selection menu actually offers on the running system + * version — they must not be assumed from the copy option. Never an input + * surface: no IME, no mutation except through the "text" prop. + */ +class KRSelectableTextView : public IKRRenderViewExport { + public: + ArkUI_NodeHandle CreateNode() override; + void DidInit() override; + bool SetProp(const std::string &prop_key, const KRAnyValue &prop_value, + const KRRenderCallback event_call_back = nullptr) override; + bool ReuseEnable() override { + // Selection state must never leak across reuse. + return false; + } + + private: + void UpdateFont(); + + float font_size_ = 15; + ArkUI_FontWeight font_weight_ = ARKUI_FONT_WEIGHT_NORMAL; +}; + +#endif // CORE_RENDER_OHOS_KRSELECTABLETEXTVIEW_H diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerContentOffset.h b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerContentOffset.h new file mode 100644 index 000000000..a97091556 --- /dev/null +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/components/scroller/KRScrollerContentOffset.h @@ -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. + */ + +#ifndef CORE_RENDER_OHOS_KRSCROLLERCONTENTOFFSET_H +#define CORE_RENDER_OHOS_KRSCROLLERCONTENTOFFSET_H + +struct KRScrollerAxisOffsetAdjustment { + bool should_adjust = false; + float target_offset = 0.0f; +}; + +// OHOS implements contentInset by applying a margin to the content view. The +// leading resting offset is therefore always zero on either axis; subtracting +// start/top here would count the same inset again. Only the trailing inset +// extends the maximum scroll range. +inline KRScrollerAxisOffsetAdjustment KRResolveMarginInsetAxisOffset( + float current_offset, float content_size, float frame_size, float trailing_inset) { + if (content_size <= frame_size || current_offset < 0.0f) { + return {true, 0.0f}; + } + + const auto max_offset = content_size + trailing_inset - frame_size; + if (current_offset > max_offset) { + return {true, max_offset}; + } + return {false, current_offset}; +} + +#endif // CORE_RENDER_OHOS_KRSCROLLERCONTENTOFFSET_H 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 9b87fd55c..bb4557bb9 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,14 +13,19 @@ * limitations under the License. */ +#include + +#include "libohos_render/expand/components/scroller/KRScrollerContentOffset.h" #include "libohos_render/expand/components/scroller/KRScrollerView.h" #include #include +#include #include #include "libohos_render/expand/components/view/KRView.h" #include "libohos_render/foundation/type/KRRenderValue.h" #include "libohos_render/utils/KRJSONObject.h" +#include "libohos_render/utils/KRRenderLoger.h" #ifdef __cplusplus @@ -146,7 +151,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; } } @@ -430,6 +436,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_) { @@ -438,10 +445,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) { @@ -470,7 +478,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(); @@ -508,36 +518,15 @@ KRPoint KRScrollerView::MaxContentOffsetInContentInset( auto content_frame = content_view_->GetFrame(); auto current_offset = GetContentOffset(); - if (direction_row_) { - float content_size = content_frame.width; - float frame_size = frame.width; - if (content_size <= frame_size) { - return KRPoint(); - } - // 上/左越界:offset 滚到了 inset 头部之前 - if (current_offset.x < -content_inset->start) { - return KRPoint{-content_inset->start, 0}; - } - // 下/右越界:offset 滚过了内容尾部 - float max_offset = content_size + content_inset->end - frame_size; - if (current_offset.x > max_offset) { - return KRPoint{max_offset, 0}; - } - } else { - float content_size = content_frame.height; - float frame_size = frame.height; - if (content_size <= frame_size) { - return KRPoint(); - } - // 上越界 - if (current_offset.y < -content_inset->top) { - return KRPoint{0, -content_inset->top}; - } - // 下越界 - float max_offset = content_size + content_inset->bottom - frame_size; - if (current_offset.y > max_offset) { - return KRPoint{0, max_offset}; - } + const auto adjustment = + direction_row_ + ? KRResolveMarginInsetAxisOffset( + current_offset.x, content_frame.width, frame.width, content_inset->end) + : KRResolveMarginInsetAxisOffset( + current_offset.y, content_frame.height, frame.height, content_inset->bottom); + if (adjustment.should_adjust) { + return direction_row_ ? KRPoint{adjustment.target_offset, 0} + : KRPoint{0, adjustment.target_offset}; } return current_offset; @@ -823,4 +812,4 @@ void KRScrollerView::AbortContentOffsetAnimate() { ArkUI_NumberValue values[] = {{.f32 = 0}, {.f32 = 0}}; ArkUI_AttributeItem item = {values, 2}; kuikly::util::GetNodeApi()->setAttribute(GetNode(), NODE_SCROLL_BY, &item); -} \ No newline at end of file +} 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 bd2811329..e9161c24b 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,15 +119,21 @@ 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_) { super_touch_handler_ = std::make_shared(); + // enable transition:让下一个 action 把 type 重算为 SELF。 + // 否则缓存的 NONE/PARENT 会让 SELF 分支永不进入,capture 静默失效。 + // 仅在新建 handler 的 transition 重置,避免扰动进行中的 gesture。 + parent_super_touch_handler_.reset(); + super_touch_type_ = UNKNOWN; } } else { - if (super_touch_handler_) { - super_touch_handler_ = nullptr; - } + ResetSuperTouchState(); } didHand = true; } else if (kuikly::util::isEqual(prop_key, kPropNameHitTestModeOhos)) { @@ -178,14 +185,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; } @@ -236,6 +248,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, @@ -245,10 +259,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; @@ -256,9 +278,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) { @@ -344,6 +366,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_) { @@ -354,7 +377,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, @@ -394,8 +417,15 @@ 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)) { - super_touch_handler_ = nullptr; + ResetSuperTouchState(); didHande = true; } else if (kuikly::util::isEqual(prop_key, kPropNameHitTestModeOhos)) { target_hit_test_mode = ARKUI_HIT_TEST_MODE_DEFAULT; @@ -425,6 +455,17 @@ bool KRView::ResetProp(const std::string &prop_key) { return didHande; } +void KRView::ResetSuperTouchState() { + if (super_touch_handler_) { + super_touch_handler_->ClearNativeTouchConsumer(shared_from_this()); + super_touch_handler_ = nullptr; + } + native_dispatch_capture_requested_ = false; + native_dispatch_captured_gesture_ = false; + parent_super_touch_handler_.reset(); + super_touch_type_ = UNKNOWN; +} + void KRView::ProcessTouchEvent(ArkUI_NodeEvent *event) { auto input_event = kuikly::util::GetArkUIInputEvent(event); TryFireSuperTouchCancelEvent(input_event); @@ -447,20 +488,42 @@ 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()); + } + } + // 必须先消费/清掉 child 写入的 action marker;capture 命中也不能跳过清理, + // 否则 stale marker 会在下一 gesture 的同 action 被误吞。 + bool should_stop = handled; if (super_touch_handler_->GetStopPropagation(action)) { - kuikly::util::StopPropagation(event); + should_stop = true; super_touch_handler_->SetStopPropagation(action, false); } + if (native_dispatch_captured_gesture_) { + if (action == UI_TOUCH_EVENT_ACTION_UP || action == UI_TOUCH_EVENT_ACTION_CANCEL) { + native_dispatch_captured_gesture_ = false; + super_touch_handler_->ClearNativeTouchConsumer(shared_from_this()); + } + kuikly::util::StopPropagation(event); + return; + } + // capture miss / inactive capture 的唯一 fall-through 点(upstream #1508 + // handled-first 语义:handled 或 child marker 任一即停,不再依赖 stop_propagation_)。 + if (should_stop) { + kuikly::util::StopPropagation(event); + } } else if (handled) { - if (stop_propagation_) { - if (super_touch_type_ == PARENT) { - auto parent_super_touch_handler = parent_super_touch_handler_.lock(); - if (parent_super_touch_handler) { - parent_super_touch_handler->SetStopPropagation(action, true); - } - } else if (super_touch_type_ == NONE) { - kuikly::util::StopPropagation(event); + // upstream #1508:基于 touch 是否被消费决定是否冒泡,而不是 stop_propagation_。 + if (super_touch_type_ == PARENT) { + auto parent_super_touch_handler = parent_super_touch_handler_.lock(); + if (parent_super_touch_handler) { + parent_super_touch_handler->SetStopPropagation(action, true); } + } else if (super_touch_type_ == NONE) { + kuikly::util::StopPropagation(event); } } } @@ -615,6 +678,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..2e4c96afe 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); @@ -69,6 +69,9 @@ class KRView : public IKRRenderViewExport { bool HasTouchEvent(); void UpdateHitTestMode(bool shouldUseTarget); void EnsureSuperTouchType(); + // superTouch disable/reset 的统一清理:清 consumer、两枚 capture 状态、 + // parent handler 引用与 SELF 类型缓存,避免 stale SELF/consumer 残留或空解引用。 + void ResetSuperTouchState(); bool HandleTextSelectionMethods(const std::string &method, const KRAnyValue ¶ms, const KRRenderCallback &cb); void HandleCreateSelection(const KRAnyValue ¶ms); @@ -76,7 +79,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, @@ -113,6 +116,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; diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/modules/cache/KRMemoryCacheModule.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/modules/cache/KRMemoryCacheModule.cpp index 378a848dd..7733a7750 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/modules/cache/KRMemoryCacheModule.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/modules/cache/KRMemoryCacheModule.cpp @@ -19,15 +19,24 @@ #include "libohos_render/expand/modules/network/KRNetworkModule.h" #include "libohos_render/utils/KRURIHelper.h" #include +#include #include #include +#include #include +#include #ifdef __cplusplus extern "C" { #endif // Remove this declaration if compatable api is raised to 18 and above extern Image_ErrorCode OH_PixelmapNative_Destroy(OH_PixelmapNative **pixelmap) __attribute__((weak)); +// Keep Kuikly loadable on its supported API 12-14 floor. The allocator-selecting +// decoder was added in API 15 and therefore must not become a strong BIND_NOW +// dependency of libkuikly.so. +extern Image_ErrorCode OH_ImageSourceNative_CreatePixelmapUsingAllocator( + OH_ImageSourceNative *source, OH_DecodingOptions *options, IMAGE_ALLOCATOR_TYPE allocator, + OH_PixelmapNative **pixelmap) __attribute__((weak)); #ifdef __cplusplus }; #endif @@ -58,6 +67,139 @@ static bool isNetwork(const std::string &src) { static bool isAssets(const std::string &src) { return src.compare(0, KR_ASSET_PREFIX.size(), KR_ASSET_PREFIX) == 0; } +static Image_ErrorCode ValidateCpuReadableRgbaPixelmap(OH_PixelmapNative *pixelmap) { + if (pixelmap == nullptr) { + return IMAGE_BAD_PARAMETER; + } + + OH_Pixelmap_ImageInfo *info = nullptr; + Image_ErrorCode code = OH_PixelmapImageInfo_Create(&info); + if (code != IMAGE_SUCCESS || info == nullptr) { + return code == IMAGE_SUCCESS ? IMAGE_BAD_PARAMETER : code; + } + + uint32_t width = 0; + uint32_t height = 0; + uint32_t row_stride = 0; + int32_t pixel_format = PIXEL_FORMAT_UNKNOWN; + bool is_hdr = true; + code = OH_PixelmapNative_GetImageInfo(pixelmap, info); + if (code == IMAGE_SUCCESS) { + code = OH_PixelmapImageInfo_GetWidth(info, &width); + } + if (code == IMAGE_SUCCESS) { + code = OH_PixelmapImageInfo_GetHeight(info, &height); + } + if (code == IMAGE_SUCCESS) { + code = OH_PixelmapImageInfo_GetRowStride(info, &row_stride); + } + if (code == IMAGE_SUCCESS) { + code = OH_PixelmapImageInfo_GetPixelFormat(info, &pixel_format); + } + if (code == IMAGE_SUCCESS) { + code = OH_PixelmapImageInfo_GetDynamicRange(info, &is_hdr); + } + OH_PixelmapImageInfo_Release(info); + + if (code != IMAGE_SUCCESS) { + return code; + } + if (width == 0 || height == 0 || width > std::numeric_limits::max() / 4) { + return IMAGE_TOO_LARGE; + } + if (row_stride < width * 4 || pixel_format != PIXEL_FORMAT_RGBA_8888 || is_hdr) { + return IMAGE_SOURCE_UNSUPPORTED_OPTIONS; + } + if (height > std::numeric_limits::max() / row_stride) { + return IMAGE_TOO_LARGE; + } + + const size_t expected_byte_count = static_cast(row_stride) * height; + size_t byte_count = expected_byte_count; + try { + std::vector pixels(expected_byte_count); + code = OH_PixelmapNative_ReadPixels(pixelmap, pixels.data(), &byte_count); + if (code == IMAGE_SUCCESS && byte_count != expected_byte_count) { + code = IMAGE_UNKNOWN_ERROR; + } + } catch (const std::bad_alloc &) { + code = IMAGE_ALLOC_FAILED; + } + return code; +} + +static void ReleaseAndClearPixelmap(OH_PixelmapNative **pixelmap) { + if (pixelmap != nullptr && *pixelmap != nullptr) { + if (OH_PixelmapNative_Destroy != nullptr) { + OH_PixelmapNative_Destroy(pixelmap); + } else { + OH_PixelmapNative_Release(*pixelmap); + *pixelmap = nullptr; + } + } +} + +static Image_ErrorCode CreateCanvasCompatiblePixelmap(OH_ImageSourceNative *source, OH_PixelmapNative **pixelmap) { + if (source == nullptr || pixelmap == nullptr) { + return IMAGE_BAD_PARAMETER; + } + *pixelmap = nullptr; + + OH_DecodingOptions *options = nullptr; + Image_ErrorCode code = OH_DecodingOptions_Create(&options); + if (code != IMAGE_SUCCESS) { + return code; + } + if (options == nullptr) { + return IMAGE_BAD_PARAMETER; + } + + // Canvas reads the decoded pixels through OH_Drawing. Keeping HDR in AUTO lets + // ImageSource select a hardware/surface-buffer PixelMap which is not reliably + // readable by that path. Tone-map to SDR, request the format Canvas consumes, + // and force shared memory so the resulting pixels are CPU-readable. + code = OH_DecodingOptions_SetDesiredDynamicRange(options, IMAGE_DYNAMIC_RANGE_SDR); + if (code == IMAGE_SUCCESS) { + code = OH_DecodingOptions_SetPixelFormat(options, PIXEL_FORMAT_RGBA_8888); + } + if (code == IMAGE_SUCCESS) { + if (OH_ImageSourceNative_CreatePixelmapUsingAllocator != nullptr) { + code = OH_ImageSourceNative_CreatePixelmapUsingAllocator( + source, options, IMAGE_ALLOCATOR_TYPE_SHARE_MEMORY, pixelmap); + if (code == IMAGE_SUCCESS) { + // Some platform decoders report success while still returning a + // surface-buffer PixelMap. Treat the requested allocator as a + // preference, not proof that Canvas can read the result. + code = ValidateCpuReadableRgbaPixelmap(*pixelmap); + } + if (code != IMAGE_SUCCESS) { + ReleaseAndClearPixelmap(pixelmap); + KR_LOG_INFO_WITH_TAG(kMemoryCacheModuleName) + << "shared-memory decode was not Canvas-readable; retrying validated legacy decode, error code: " + << code; + } + } + + if (OH_ImageSourceNative_CreatePixelmapUsingAllocator == nullptr || code != IMAGE_SUCCESS) { + // API 12-14 have no allocator-selecting decoder. API 15+ also uses + // this retry when the allocator call succeeds but its actual result + // fails the CPU-readable contract. The same SDR/RGBA options remain + // active, and the fallback is admitted only after full validation; + // this never restores the old unvalidated HDR AUTO path. + code = OH_ImageSourceNative_CreatePixelmap(source, options, pixelmap); + if (code == IMAGE_SUCCESS) { + code = ValidateCpuReadableRgbaPixelmap(*pixelmap); + } + } + } + + OH_DecodingOptions_Release(options); + if (code != IMAGE_SUCCESS) { + ReleaseAndClearPixelmap(pixelmap); + } + return code; +} + KRAnyValue KRMemoryCacheModule::Get(const std::string &key) { auto it = cache_map_.find(key); if (it == cache_map_.end()) { @@ -119,16 +261,13 @@ KRAnyValue KRMemoryCacheModule::SetObject(const KRAnyValue ¶ms) { OH_PixelmapNative *KRMemoryCacheModule::LoadPixelmapFromLocal(std::string &src) { OH_PixelmapNative *pixelmap = nullptr; - OH_ImageSourceNative *source; + OH_ImageSourceNative *source = nullptr; auto code = OH_ImageSourceNative_CreateFromUri(src.data(), src.length(), &source); if (code == IMAGE_SUCCESS) { - // 通过图片解码参数创建PixelMap对象 - OH_DecodingOptions *ops; - if (OH_DecodingOptions_Create(&ops) == IMAGE_SUCCESS) { - // 设置为AUTO会根据图片资源格式解码,如果图片资源为HDR资源则会解码为HDR的pixelmap。 - OH_DecodingOptions_SetDesiredDynamicRange(ops, IMAGE_DYNAMIC_RANGE_AUTO); - OH_ImageSourceNative_CreatePixelmap(source, ops, &pixelmap); - OH_DecodingOptions_Release(ops); + code = CreateCanvasCompatiblePixelmap(source, &pixelmap); + if (code != IMAGE_SUCCESS) { + KR_LOG_ERROR_WITH_TAG(kMemoryCacheModuleName) + << "failed to create Canvas-compatible SDR pixelmap, error code: " << code; } OH_ImageSourceNative_Release(source); } else { @@ -296,9 +435,5 @@ void KRMemoryCacheModule::OnDestroy() { } void KRMemoryCacheModule::ReleasePixelmap(OH_PixelmapNative *pixelmap) { - if (OH_PixelmapNative_Destroy) { - OH_PixelmapNative_Destroy(&pixelmap); - } else { - OH_PixelmapNative_Release(pixelmap); - } + ReleaseAndClearPixelmap(&pixelmap); } diff --git a/core-render-ohos/src/main/cpp/libohos_render/expand/modules/file/KRFileModule.cpp b/core-render-ohos/src/main/cpp/libohos_render/expand/modules/file/KRFileModule.cpp index fb3809912..58c347061 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/expand/modules/file/KRFileModule.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/expand/modules/file/KRFileModule.cpp @@ -17,10 +17,16 @@ #include #include +#include #include #include +#include +#include +#include #include #include +#include +#include #include "libohos_render/utils/KRJSONObject.h" @@ -51,6 +57,65 @@ static KRAnyValue MakeResult(const std::string &key, const std::string &value) { return KRRenderValue::Make(result); } +// One process-wide worker serializes file operations from every Pager. A stable operation id is +// recorded before callback delivery; if a Pager is destroyed after its native commit, retrying the +// same operation through a new Pager reaches this FIFO after the original and is acknowledged +// without writing twice. +class ProfilerFileWorker { + public: + using Task = std::function &)>; + + static ProfilerFileWorker &Instance() { + static ProfilerFileWorker worker; + return worker; + } + + void Enqueue(Task task) { + { + std::lock_guard lock(mutex_); + tasks_.push_back(std::move(task)); + } + condition_.notify_one(); + } + + private: + ProfilerFileWorker() : worker_([this]() { Run(); }) {} + + ~ProfilerFileWorker() { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + condition_.notify_one(); + if (worker_.joinable()) { + worker_.join(); + } + } + + void Run() { + while (true) { + Task task; + { + std::unique_lock lock(mutex_); + condition_.wait(lock, [this]() { return stopping_ || !tasks_.empty(); }); + if (stopping_ && tasks_.empty()) { + return; + } + task = std::move(tasks_.front()); + tasks_.pop_front(); + } + task(completedOperationIds_); + } + } + + std::mutex mutex_; + std::condition_variable condition_; + std::deque tasks_; + std::unordered_set completedOperationIds_; + bool stopping_ = false; + std::thread worker_; +}; + // --------------------------------------------------------------------------- // 获取 profiler 写入目录(filesDir/KuiklyProfiler/) // --------------------------------------------------------------------------- @@ -75,9 +140,12 @@ void KRFileModule::WriteFile(const KRAnyValue ¶ms, const KRRenderCallback &c auto jsonObj = util::JSONObject::Parse(params->toString()); const std::string filename = jsonObj->GetString("filename"); const std::string content = jsonObj->GetString("content"); + const std::string operationId = jsonObj->GetString("operationId"); - if (filename.empty() || content.empty()) { - if (callback) callback(MakeResult("error", "missing filename or content")); + // Empty content is a valid overwrite operation used to truncate the previous profiler report + // when a new session starts. + if (filename.empty()) { + if (callback) callback(MakeResult("error", "missing filename")); return; } @@ -89,16 +157,29 @@ void KRFileModule::WriteFile(const KRAnyValue ¶ms, const KRRenderCallback &c const std::string filePath = dir + "/" + filename; - std::thread([filePath, content, callback]() { + ProfilerFileWorker::Instance().Enqueue( + [filePath, content, operationId, callback]( + std::unordered_set &completedOperationIds) { + if (!operationId.empty() && completedOperationIds.count(operationId) > 0) { + if (callback) callback(MakeResult("path", filePath)); + return; + } FILE *fp = fopen(filePath.c_str(), "w"); if (!fp) { if (callback) callback(MakeResult("error", "fopen failed")); return; } - fwrite(content.c_str(), 1, content.size(), fp); - fclose(fp); + const size_t written = fwrite(content.c_str(), 1, content.size(), fp); + const int closeResult = fclose(fp); + if (written != content.size() || closeResult != 0) { + if (callback) callback(MakeResult("error", "file write failed")); + return; + } + if (!operationId.empty()) { + completedOperationIds.insert(operationId); + } if (callback) callback(MakeResult("path", filePath)); - }).detach(); + }); } // --------------------------------------------------------------------------- @@ -108,6 +189,7 @@ void KRFileModule::AppendFile(const KRAnyValue ¶ms, const KRRenderCallback & auto jsonObj = util::JSONObject::Parse(params->toString()); const std::string filename = jsonObj->GetString("filename"); const std::string content = jsonObj->GetString("content"); + const std::string operationId = jsonObj->GetString("operationId"); if (filename.empty() || content.empty()) { if (callback) callback(MakeResult("error", "missing filename or content")); @@ -122,18 +204,31 @@ void KRFileModule::AppendFile(const KRAnyValue ¶ms, const KRRenderCallback & const std::string filePath = dir + "/" + filename; - std::thread([filePath, content, callback]() { + ProfilerFileWorker::Instance().Enqueue( + [filePath, content, operationId, callback]( + std::unordered_set &completedOperationIds) { + if (!operationId.empty() && completedOperationIds.count(operationId) > 0) { + if (callback) callback(MakeResult("path", filePath)); + return; + } FILE *fp = fopen(filePath.c_str(), "a"); if (!fp) { if (callback) callback(MakeResult("error", "fopen failed")); return; } // 追加内容 + 换行,适合 JSONL 格式 - fwrite(content.c_str(), 1, content.size(), fp); - fwrite("\n", 1, 1, fp); - fclose(fp); + const size_t contentWritten = fwrite(content.c_str(), 1, content.size(), fp); + const size_t newlineWritten = fwrite("\n", 1, 1, fp); + const int closeResult = fclose(fp); + if (contentWritten != content.size() || newlineWritten != 1 || closeResult != 0) { + if (callback) callback(MakeResult("error", "file append failed")); + return; + } + if (!operationId.empty()) { + completedOperationIds.insert(operationId); + } if (callback) callback(MakeResult("path", filePath)); - }).detach(); + }); } // --------------------------------------------------------------------------- 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..c12cb5dd8 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,24 +23,28 @@ #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; }; +struct IdleTaskContext { + std::function func; + uint64_t admittedGeneration; + uint8_t quietTurns; +}; + // 主线程 uv_loop(来自 ArkTS 主线程的 napi_env)。 // 该 loop 的生命周期由 ArkTS 运行时管理,KRMainThread 仅持有指针、不创建也不销毁。 uv_loop_t *g_main_loop = nullptr; // 主线程线程 ID(即 Export 被调用所在的线程)。 std::thread::id g_main_thread_id; std::atomic g_initialized{false}; +std::atomic g_main_normal_generation{0}; // 跨线程把任务投递到主线程的 async 句柄,必须在主线程(loop 线程)上 init。 uv_async_t g_main_async{}; @@ -54,8 +58,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 +79,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) { @@ -101,10 +107,105 @@ void StartTimerOnMainThread(std::function task, int delayMs) { } } +void StartIdleOnMainThread( + std::function task, + uint64_t admittedGeneration, + uint8_t quietTurns = 0 +) { + auto *check = new uv_check_t(); + auto *holder = new IdleTaskContext{std::move(task), admittedGeneration, quietTurns}; + check->data = holder; + int ret = uv_check_init(g_main_loop, check); + if (ret != 0) { + KR_LOG_ERROR << "KRMainThread::StartIdleOnMainThread uv_check_init failed, ret=" << ret + << "; fallback to immediate callback."; + if (holder->func) { + holder->func(); + } + delete holder; + delete check; + return; + } + ret = uv_check_start(check, [](uv_check_t *handle) { + auto *ctx = static_cast(handle->data); + uv_check_stop(handle); + bool hasPendingNormalTask = false; + { + std::lock_guard lock(g_queue_mutex); + hasPendingNormalTask = !g_pending_queue.empty(); + } + if (ctx != nullptr) { + const bool generationChanged = + g_main_normal_generation.load(std::memory_order_acquire) != + ctx->admittedGeneration; + // uv_idle_t is not an idle signal: libuv invokes it on every loop + // iteration and it forces a zero poll timeout. A check handle runs + // after poll/pending callbacks without changing the poll timeout; + // uv_backend_timeout == 0 means the loop has immediate work for its + // next turn, including ArkTS handles registered on this same loop. + const bool loopMayWait = uv_backend_timeout(g_main_loop) != 0; + if (hasPendingNormalTask || generationChanged || !loopMayWait || ctx->quietTurns < 1) { + const uint8_t nextQuietTurns = + (!hasPendingNormalTask && !generationChanged && loopMayWait) + ? static_cast(ctx->quietTurns + 1) + : 0; + StartIdleOnMainThread( + std::move(ctx->func), + g_main_normal_generation.load(std::memory_order_acquire), + nextQuietTurns + ); + ctx->func = nullptr; + } else if (ctx->func) { + ctx->func(); + } + } + uv_close(reinterpret_cast(handle), [](uv_handle_t *h) { + auto *checkHandle = reinterpret_cast(h); + delete static_cast(checkHandle->data); + delete checkHandle; + }); + }); + if (ret != 0) { + KR_LOG_ERROR << "KRMainThread::StartIdleOnMainThread uv_check_start failed, ret=" << ret + << "; fallback to immediate callback."; + if (holder->func) { + holder->func(); + } + uv_close(reinterpret_cast(check), [](uv_handle_t *h) { + auto *checkHandle = reinterpret_cast(h); + delete static_cast(checkHandle->data); + delete checkHandle; + }); + return; + } + if (quietTurns > 0) { + // A check handle runs only after poll returns. When the loop is truly + // idle its backend timeout is infinite, so the second stability turn + // needs one bounded control-plane wake or it could starve forever. + // This async callback carries no app task and does not advance the + // foreground generation; it only makes the already-installed check + // observable on the next loop turn. + ret = uv_async_send(&g_main_async); + if (ret != 0) { + KR_LOG_ERROR << "KRMainThread::StartIdleOnMainThread control wake failed, ret=" << ret + << "; fallback to immediate callback."; + uv_check_stop(check); + if (holder->func) { + holder->func(); + } + uv_close(reinterpret_cast(check), [](uv_handle_t *h) { + auto *checkHandle = reinterpret_cast(h); + delete static_cast(checkHandle->data); + delete checkHandle; + }); + } + } +} + // 主线程 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 +219,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); } @@ -127,6 +228,7 @@ void OnMainAsync(uv_async_t * /*handle*/) { // 把任务塞进队列并唤醒主线程 loop。 void EnqueueAndNotify(std::function task, int delayMs) { + g_main_normal_generation.fetch_add(1, std::memory_order_release); auto pending = std::make_unique(); pending->func = std::move(task); pending->delayMs = delayMs; @@ -176,22 +278,19 @@ 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; } if (IsCurrentMainThread()) { + g_main_normal_generation.fetch_add(1, std::memory_order_release); // 已经在主线程(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,13 +318,57 @@ 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 回合"才执行。 EnqueueAndNotify(std::move(task), 0); } +void KRMainThread::RunOnMainThreadWhenIdle(std::function task) { + if (!task) { + return; + } + if (!g_initialized.load() || g_main_loop == nullptr) { + KR_LOG_ERROR << "KRMainThread::RunOnMainThreadWhenIdle before Export, fallback to inline run"; + task(); + return; + } + const uint64_t generation = g_main_normal_generation.load(std::memory_order_acquire); + if (IsCurrentMainThread()) { + StartIdleOnMainThread(std::move(task), generation); + return; + } + // Installing the idle handle is control-plane work, not foreground app + // work, so do not route through EnqueueAndNotify (which would invalidate + // its own admission). Reuse the main async queue with a wrapper whose + // captured generation is checked again by the idle callback. + auto completed = std::make_shared>(false); + auto sharedTask = std::make_shared>(std::move(task)); + auto invokeOnce = [completed, sharedTask]() { + if (!completed->exchange(true, std::memory_order_acq_rel) && *sharedTask) { + (*sharedTask)(); + } + }; + auto pending = std::make_unique(); + pending->func = [completed, invokeOnce, generation]() mutable { + if (!completed->load(std::memory_order_acquire)) { + StartIdleOnMainThread(std::move(invokeOnce), generation); + } + }; + pending->delayMs = 0; + { + std::lock_guard lock(g_queue_mutex); + g_pending_queue.push(std::move(pending)); + } + const int ret = uv_async_send(&g_main_async); + if (ret != 0) { + KR_LOG_ERROR << "KRMainThread::RunOnMainThreadWhenIdle uv_async_send failed, ret=" << ret + << "; fallback to immediate callback."; + invokeOnce(); + } +} + bool KRMainThread::IsCurrentOnMainThread() { // Export 之前 g_main_thread_id 未赋值,无法做出可靠判断; // 此时一律返回 false,让调用方走"非主线程"安全路径(跨线程投递)。 @@ -233,4 +376,4 @@ bool KRMainThread::IsCurrentOnMainThread() { return false; } return IsCurrentMainThread(); -} \ No newline at end of file +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.h index be7d7ff6f..80fd02580 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.h +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRMainThread.h @@ -41,6 +41,9 @@ class KRMainThread { */ static void RunOnMainThreadForNextLoop(std::function task); + /** Run one bounded task after two stable main-loop turns with no immediate work. */ + static void RunOnMainThreadWhenIdle(std::function task); + /** * @brief 当前调用线程是否为 ArkTS 主线程(即 Export 时记录的 loop 线程)。 * 仅在 Export 完成后返回值才有意义;未初始化时一律返回 false。 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..dacafdcb6 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; @@ -185,15 +182,11 @@ void KRThread::OnAsync() { std::lock_guard lock(m_queueMutex); std::swap(local, m_pending); } - if (local.empty()) { - return; - } - - // 抢执行权:阻塞等待。即使此刻有 DirectRunOnCurThread 持锁, - // worker 也只是被挂起到对方 unlock,期间不忙等、不浪费 CPU。 - // worker 线程不会自我重入(task 内嵌套提交同步任务走 m_isExecutingTask 快路径), - // 故对 m_taskMutex 阻塞 lock 不会自死锁。 - { + if (!local.empty()) { + // 抢执行权:阻塞等待。即使此刻有 DirectRunOnCurThread 持锁, + // worker 也只是被挂起到对方 unlock,期间不忙等、不浪费 CPU。 + // worker 线程不会自我重入(task 内嵌套提交同步任务走 m_isExecutingTask 快路径), + // 故对 m_taskMutex 阻塞 lock 不会自死锁。 std::lock_guard taskLock(m_taskMutex); m_isExecutingTask.store(true); while (!local.empty()) { @@ -202,12 +195,53 @@ 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); } + + // Normal work always wins. Re-check the shared queue after the captured + // batch completes because producers may have enqueued foreground work + // while the worker was executing it. Run at most one idle callback per + // loop turn so the next normal enqueue gets an admission point. + std::function idleTask; + { + std::lock_guard lock(m_queueMutex); + if (m_pending.empty() && !m_idlePending.empty()) { + idleTask = std::move(m_idlePending.front()); + m_idlePending.pop(); + } + } + if (!idleTask) { + return; + } + + { + std::lock_guard taskLock(m_taskMutex); + struct IdleExecutionGuard { + std::atomic &executing; + explicit IdleExecutionGuard(std::atomic &flag) : executing(flag) { + executing.store(true); + int ret = OH_QoS_SetThreadQoS(QoS_Level::QOS_BACKGROUND); + if (ret != 0) { + KR_LOG_ERROR << "KRThread idle OH_QoS_SetThreadQoS(background) failed, err=" << ret; + } + } + ~IdleExecutionGuard() { + int ret = OH_QoS_SetThreadQoS(QoS_Level::QOS_USER_INTERACTIVE); + if (ret != 0) { + KR_LOG_ERROR << "KRThread idle OH_QoS_SetThreadQoS(restore) failed, err=" << ret; + } + executing.store(false); + } + } idleGuard(m_isExecutingTask); + idleTask(); + } } void KRThread::AsyncCloseCb(uv_handle_t *handle) { @@ -261,7 +295,7 @@ void KRThread::TimerCb(uv_timer_t *handle) { // 绝不能在此裸跑 ctx->exec:正常路径 task 必须在 worker 线程且受 // m_taskMutex 保护执行,fallback 直接调用会绕开互斥语义、并可能与 // 正在借位执行的 DirectRunOnCurThread 并发踩踏 kuikly 上下文。 - // RunWithFatalGuard 只挡异常,不挡数据竞争 —— 这里选择丢弃任务。 + // 这里不依赖任何 C++ catch,直接丢弃任务。 // // 双层策略(与 OnAsync 未知句柄分支保持一致): // * debug:assert(false) 让状态损坏第一时间暴露到崩溃栈; @@ -317,21 +351,32 @@ void KRThread::DispatchAsync(std::function task, int delayMilliseconds) uv_async_send(&m_async); } +void KRThread::DispatchIdle(std::function task) { + if (!task) { + return; + } + if (!m_loopReady.load()) { + KR_LOG_ERROR << "KRThread::DispatchIdle before loop ready, drop task"; + return; + } + { + std::lock_guard lock(m_queueMutex); + m_idlePending.push(std::move(task)); + } + uv_async_send(&m_async); +} + void KRThread::DirectRunOnCurThread(const std::function &task) { if (!task) { return; } 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 +390,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 +404,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; } @@ -369,4 +415,4 @@ void KRThread::DirectRunOnCurThread(const std::function &task) { KR_LOG_INFO << "DispatchAsync when run DirectRunOnCurThread"; DispatchAsync(task); } -} \ No newline at end of file +} diff --git a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.h b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.h index 17eae1012..fb6d190fc 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.h +++ b/core-render-ohos/src/main/cpp/libohos_render/foundation/thread/KRThread.h @@ -45,6 +45,13 @@ class KRThread { */ void DispatchAsync(std::function task, int delayMilliseconds = 0); + /** + * Executes one bounded speculative task only after the immediate queue has + * drained. Idle tasks run at background QoS and yield to newly queued + * normal work between callbacks. + */ + void DispatchIdle(std::function task); + /** * @brief 在当前调用线程上"直跑"任务,与 worker 线程协调互斥; * 若长时间拿不到 mutex 则降级为 DispatchAsync。沿用旧语义。 @@ -122,6 +129,7 @@ class KRThread { // OnAsync 在 loop 线程上起 timer。 std::mutex m_queueMutex; std::queue> m_pending; + std::queue> m_idlePending; std::queue> m_pendingTimers; // ---- 任务执行权 / 同步主任务标志 ---- 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..0e1239ff5 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 @@ -28,6 +28,7 @@ class KRContextSchedulerInternal { public: virtual ~KRContextSchedulerInternal() = default; virtual void ScheduleTask(int delayMs, const KRSchedulerTask &task) = 0; + virtual void ScheduleIdleTask(const KRSchedulerTask &task) = 0; virtual void ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) = 0; virtual void DirectRunOnMainThread(bool isSync, const KRSchedulerTask &task) = 0; @@ -37,6 +38,7 @@ class KRContextSchedulerInternal { class KRContextSchedulerMultiThreaded : public KRContextSchedulerInternal { public: void ScheduleTask(int delayMs, const KRSchedulerTask &task) override; + void ScheduleIdleTask(const KRSchedulerTask &task) override; void ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) override; void DirectRunOnMainThread(bool isSync, const KRSchedulerTask &task) override; bool IsCurrentOnContextThread() override; @@ -87,6 +89,15 @@ void KRContextSchedulerMultiThreaded::ScheduleTask(int delayMs, const KRSchedule GetContextThread()->DispatchAsync(task, delayMs); } +void KRContextSchedulerMultiThreaded::ScheduleIdleTask(const KRSchedulerTask &task) { + auto *contextThread = GetContextThread(); + contextThread->DispatchIdle([contextThread, task]() { + KRMainThread::RunOnMainThreadWhenIdle([contextThread, task]() { + contextThread->DispatchIdle(task); + }); + }); +} + void KRContextSchedulerMultiThreaded::ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) { if (!task) { return; @@ -135,8 +146,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 @@ -191,6 +202,7 @@ bool KRContextSchedulerMultiThreaded::IsCurrentOnContextThread() { class KRContextSchedulerSingleThreaded : public KRContextSchedulerInternal { public: void ScheduleTask(int delayMs, const KRSchedulerTask &task) override; + void ScheduleIdleTask(const KRSchedulerTask &task) override; void ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) override; void DirectRunOnMainThread(bool isSync, const KRSchedulerTask &task) override; bool IsCurrentOnContextThread() override; @@ -210,6 +222,10 @@ void KRContextSchedulerSingleThreaded::ScheduleTask(int delayMs, const KRSchedul KRMainThread::RunOnMainThread(task, delayMs); } +void KRContextSchedulerSingleThreaded::ScheduleIdleTask(const KRSchedulerTask &task) { + KRMainThread::RunOnMainThreadWhenIdle(task); +} + void KRContextSchedulerSingleThreaded::ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) { if (sync) { task(); @@ -245,6 +261,9 @@ std::shared_ptr KRContextScheduler::GetInstance() { void KRContextScheduler::ScheduleTask(int delayMs, const KRSchedulerTask &task) { GetInstance()->ScheduleTask(delayMs, task); } +void KRContextScheduler::ScheduleIdleTask(const KRSchedulerTask &task) { + GetInstance()->ScheduleIdleTask(task); +} void KRContextScheduler::ScheduleTaskOnMainThread(bool sync, const KRSchedulerTask &task) { GetInstance()->ScheduleTaskOnMainThread(sync, task); } diff --git a/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.h b/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.h index 669efe4a7..89e6f5080 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.h +++ b/core-render-ohos/src/main/cpp/libohos_render/scheduler/KRContextScheduler.h @@ -35,6 +35,9 @@ class KRContextScheduler { */ static void ScheduleTask(int delayMs, const KRSchedulerTask &task); + /** Schedule one bounded task after normal context work drains. */ + static void ScheduleIdleTask(const KRSchedulerTask &task); + /** * Context线程调度任务到主线程执行(注:该方法只能在主线程或Context线程被调用) * @param sync 是否同步执行 diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.cpp b/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.cpp index 3f46adc32..a454cd627 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.cpp +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.cpp @@ -129,6 +129,18 @@ OH_Drawing_TextDecoration ConvertToTextDecoration(const std::string &textDecorat return TEXT_DECORATION_NONE; } +double ConvertToTextDecorationThicknessScale(double thicknessPx, double fontSizePx) { + // OH_Drawing_SetTextStyleDecorationThicknessScale takes a MULTIPLIER, not + // an absolute width. The OHOS text engine paints a decoration line as + // strokePx = fontSizePx * UNDER_LINE_THICKNESS_RATIO * scale + // with UNDER_LINE_THICKNESS_RATIO = 1/18 (skparagraph Decorations under + // OHOS_SUPPORT; identical on 5.0.x-Release and m133). The scale must + // divide out that base; dividing by fontSize alone renders thickness/18, + // which collapses any sane design value to a sub-pixel hairline. + constexpr double kEngineUnderlineThicknessRatio = 1.0 / 18.0; + return thicknessPx / (fontSizePx * kEngineUnderlineThicknessRatio); +} + OH_Drawing_EllipsisModal ConvertToTextBreakMode(const std::string &breakeMode) { if (breakeMode == "middle") { return ELLIPSIS_MODAL_MIDDLE; diff --git a/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.h b/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.h index eae428db7..07cd1c302 100644 --- a/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.h +++ b/core-render-ohos/src/main/cpp/libohos_render/utils/KRConvertUtil.h @@ -42,6 +42,8 @@ OH_Drawing_TextAlign ConvertToTextAlign(const std::string &textAlign); OH_Drawing_TextDecoration ConvertToTextDecoration(const std::string &textDecoration); +double ConvertToTextDecorationThicknessScale(double thicknessPx, double fontSizePx); + OH_Drawing_EllipsisModal ConvertToTextBreakMode(const std::string &breakeMode); OH_Drawing_FontStyle ConvertToFontStyle(const std::string &fontStyle); 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..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 @@ -924,10 +932,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..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 @@ -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 = 0); ArkUI_ScrollState GetArkUIScrollerState(ArkUI_NodeEvent *event, int scroll_state_index); @@ -196,7 +197,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-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-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 +} diff --git a/core-render-ohos/src/test/cpp/run_scroller_content_inset_offset_test.sh b/core-render-ohos/src/test/cpp/run_scroller_content_inset_offset_test.sh new file mode 100755 index 000000000..0b0ebe10e --- /dev/null +++ b/core-render-ohos/src/test/cpp/run_scroller_content_inset_offset_test.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(cd "$script_dir/../../../.." && pwd)" +build_dir="$(mktemp -d)" +trap 'rm -rf "$build_dir"' EXIT + +"${CXX:-c++}" \ + -std=c++17 \ + -Wall \ + -Wextra \ + -Werror \ + -I"$repo_root/core-render-ohos/src/main/cpp" \ + "$script_dir/scroller_content_inset_offset_test.cpp" \ + -o "$build_dir/scroller_content_inset_offset_test" + +"$build_dir/scroller_content_inset_offset_test" diff --git a/core-render-ohos/src/test/cpp/scroller_content_inset_offset_test.cpp b/core-render-ohos/src/test/cpp/scroller_content_inset_offset_test.cpp new file mode 100644 index 000000000..00053ec9d --- /dev/null +++ b/core-render-ohos/src/test/cpp/scroller_content_inset_offset_test.cpp @@ -0,0 +1,43 @@ +/* + * 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. + */ + +#include +#include + +#include "libohos_render/expand/components/scroller/KRScrollerContentOffset.h" + +static void AssertAdjustment(const KRScrollerAxisOffsetAdjustment &actual, + bool should_adjust, float target_offset) { + assert(actual.should_adjust == should_adjust); + assert(actual.target_offset == target_offset); +} + +int main() { + // Horizontal start and vertical top share this axis resolver. Margin owns + // the inset displacement, so a negative offset always rests at zero. + AssertAdjustment(KRResolveMarginInsetAxisOffset(-80.0f, 400.0f, 200.0f, 0.0f), true, 0.0f); + AssertAdjustment(KRResolveMarginInsetAxisOffset(0.0f, 400.0f, 200.0f, 0.0f), false, 0.0f); + AssertAdjustment(KRResolveMarginInsetAxisOffset(40.0f, 400.0f, 200.0f, 0.0f), false, 40.0f); + + // Content that already fits has no scroll range. + AssertAdjustment(KRResolveMarginInsetAxisOffset(25.0f, 180.0f, 200.0f, 0.0f), true, 0.0f); + + // The trailing inset extends the maximum range and is clamped once. + AssertAdjustment(KRResolveMarginInsetAxisOffset(130.0f, 300.0f, 200.0f, 30.0f), false, 130.0f); + AssertAdjustment(KRResolveMarginInsetAxisOffset(150.0f, 300.0f, 200.0f, 30.0f), true, 130.0f); + + std::cout << "OHOS scroller margin-inset axis offset test: PASS\n"; + return 0; +} 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/h5/build.gradle.kts b/core-render-web/h5/build.gradle.kts index 3808d10ba..6540a219d 100644 --- a/core-render-web/h5/build.gradle.kts +++ b/core-render-web/h5/build.gradle.kts @@ -6,7 +6,6 @@ plugins { // Import maven publishing plugin id("maven-publish") } - // maven 产物 groupId,com.tencent.kuikly group = MavenConfig.GROUP_WEB // maven 产物版本,这里统一使用 render 的版本号 @@ -64,4 +63,3 @@ kotlin { } } } - 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/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( diff --git a/core/src/appleMain/iosInterop/cinterop/ios.def b/core/src/appleMain/iosInterop/cinterop/ios.def index b7cfefd8e..8b2e6fd05 100644 --- a/core/src/appleMain/iosInterop/cinterop/ios.def +++ b/core/src/appleMain/iosInterop/cinterop/ios.def @@ -7,10 +7,13 @@ package = com.tencent.kuikly /// Schedule a task on the Kuikly context queue for the given pagerId. extern void com_tencent_kuikly_ScheduleContextTask(const char* pagerId, void (*onSchedule)(const char* pagerId)); +/// Schedule a task after both the Kuikly context queue and main run loop are idle. +extern void com_tencent_kuikly_ScheduleContextIdleTask(const char* pagerId, void (*onSchedule)(const char* pagerId)); + /// Return true if the current thread is the context thread. extern bool com_tencent_kuikly_IsCurrentOnContextThread(const char* pagerId); long long com_tencent_kuikly_GetThreadCPUTimeInNanoseconds() { // 获取当前线程的 CPU 时间,返回纳秒 return clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID); -} \ No newline at end of file +} 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 diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/ViewConst.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/ViewConst.kt index b497e5da3..3849c8440 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/ViewConst.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/ViewConst.kt @@ -43,6 +43,7 @@ object ViewConst { const val TYPE_TEXT_FIELD = "KRTextFieldView" const val TYPE_MASK = "KRMaskView" const val TYPE_TEXT_AREA = "KRTextAreaView" + const val TYPE_SELECTABLE_TEXT = "KRSelectableTextView" const val TYPE_SCROLL_CONTENT_VIEW = "KRScrollContentView" const val TYPE_BLUR_VIEW = "KRBlurView" diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/attr/IStyleAttr.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/attr/IStyleAttr.kt index a4aa4cdf9..7052a7953 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/attr/IStyleAttr.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/base/attr/IStyleAttr.kt @@ -201,6 +201,8 @@ interface IStyleAttr { */ enum class AccessibilityRole(val roleName: String) { NONE("none"), + /** Excludes this view and its descendants from the accessibility tree. */ + HIDDEN("hidden"), /** 表示视图是一个按钮 */ BUTTON("button"), /** 表示视图是一个搜索框 */ @@ -213,4 +215,4 @@ enum class AccessibilityRole(val roleName: String) { CHECKBOX("checkbox") } -typealias ClipPathBuilder = PathApi.(width: Float, height: Float) -> Unit \ No newline at end of file +typealias ClipPathBuilder = PathApi.(width: Float, height: Float) -> Unit diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/module/FileModule.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/module/FileModule.kt index 2d16b8894..62608349a 100644 --- a/core/src/commonMain/kotlin/com/tencent/kuikly/core/module/FileModule.kt +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/module/FileModule.kt @@ -33,6 +33,7 @@ class FileModule : Module() { private const val PARAM_FILENAME = "filename" private const val PARAM_CONTENT = "content" + private const val PARAM_OPERATION_ID = "operationId" private const val KEY_PATH = "path" private const val KEY_ERROR = "error" } @@ -45,9 +46,37 @@ class FileModule : Module() { * @param callback 完成回调,result["path"] 为写入路径,result["error"] 为错误信息 */ fun writeFile(filename: String, content: String, callback: CallbackFn? = null) { + writeFileInternal(filename, content, operationId = null, callback = callback) + } + + /** + * Writes [content] with a process-unique idempotency key. + * + * Profiler output can outlive the Pager whose bridge accepted the native operation. A retry on + * another live Pager therefore reuses [operationId], allowing host renderers to acknowledge an + * already committed write without applying it twice. + */ + fun writeFile( + filename: String, + content: String, + operationId: String, + callback: CallbackFn? + ) { + writeFileInternal(filename, content, operationId, callback) + } + + private fun writeFileInternal( + filename: String, + content: String, + operationId: String?, + callback: CallbackFn? + ) { val param = JSONObject().apply { put(PARAM_FILENAME, filename) put(PARAM_CONTENT, content) + if (!operationId.isNullOrEmpty()) { + put(PARAM_OPERATION_ID, operationId) + } } asyncToNativeMethod(METHOD_WRITE_FILE, param, callback) } @@ -61,9 +90,31 @@ class FileModule : Module() { * @param callback 完成回调,result["path"] 为写入路径,result["error"] 为错误信息 */ fun appendFile(filename: String, content: String, callback: CallbackFn? = null) { + appendFileInternal(filename, content, operationId = null, callback = callback) + } + + /** Idempotent-key overload used by process-wide profiler file-operation recovery. */ + fun appendFile( + filename: String, + content: String, + operationId: String, + callback: CallbackFn? + ) { + appendFileInternal(filename, content, operationId, callback) + } + + private fun appendFileInternal( + filename: String, + content: String, + operationId: String?, + callback: CallbackFn? + ) { val param = JSONObject().apply { put(PARAM_FILENAME, filename) put(PARAM_CONTENT, content) + if (!operationId.isNullOrEmpty()) { + put(PARAM_OPERATION_ID, operationId) + } } asyncToNativeMethod(METHOD_APPEND_FILE, param, callback) } 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/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/InputView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/InputView.kt index 088de9a04..a182cdb38 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,9 @@ class InputAttr : Attr() { data class InputParams( val text: String, val imeAction: String? = null, - val length: Int? = null + val length: Int? = null, + val focusRequestId: Long? = null, + val focusIntentOnly: Boolean = false, ) data class KeyboardParams( @@ -457,7 +459,14 @@ 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 = text, + focusRequestId = focusRequestId, + focusIntentOnly = it.optBoolean("focusIntentOnly"), + ), + ) } } @@ -469,7 +478,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/RichTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/RichTextView.kt index dbe633fc7..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 @@ -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,11 +449,157 @@ 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 private var longPressHandlerFn: ((LongPressParams) -> Unit)? = null + fun slockInlineCode(enabled: Boolean = true): TextSpan { + setProp(TextConst.SLOCK_INLINE_CODE, if (enabled) 1 else 0) + return this + } + + fun slockInlineCodeTrailingMargin(enabled: Boolean = true): TextSpan { + setProp(TextConst.SLOCK_INLINE_CODE_TRAILING_MARGIN, if (enabled) 1 else 0) + 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 + } + + 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 事件处理函数 + */ fun click(handler: (ClickParams) -> Unit) { clickHandlerFn = handler } 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..6dc38bc11 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 @@ -436,6 +436,14 @@ open class ScrollerAttr : ContainerAttr() { BOUNCES_ENABLE with bouncesEnable.toInt() LIMIT_BOUNCES_ENABLE with limitHeaderBounces.toInt() } + + /** + * Enables UIKit's interactive keyboard dismissal while this scroller is dragged on iOS. + * Other render targets ignore this platform-specific property. + */ + fun keyboardDismissModeInteractiveIOS(enable: Boolean) { + KEYBOARD_DISMISS_MODE_INTERACTIVE_IOS with enable.toInt() + } // 是否显示滚动指示进度条(默认显示) fun showScrollerIndicator(value: Boolean) { SHOW_SCROLLER_INDICATOR with value.toInt() @@ -513,6 +521,7 @@ open class ScrollerAttr : ContainerAttr() { companion object { const val SCROLL_ENABLED = "scrollEnabled" const val BOUNCES_ENABLE = "bouncesEnable" + const val KEYBOARD_DISMISS_MODE_INTERACTIVE_IOS = "keyboardDismissModeInteractiveIOS" const val LIMIT_BOUNCES_ENABLE = "limitHeaderBounces" const val SHOW_SCROLLER_INDICATOR = "showScrollerIndicator" const val PAGING_ENABLED = "pagingEnabled" @@ -846,4 +855,4 @@ data class SetContentOffsetAnimation(private val durationMs: Int, val damping: F return SetContentOffsetAnimation(durationMs, damping, velocity); } } -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/SelectableTextView.kt b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/SelectableTextView.kt new file mode 100644 index 000000000..d9f5e7a7b --- /dev/null +++ b/core/src/commonMain/kotlin/com/tencent/kuikly/core/views/SelectableTextView.kt @@ -0,0 +1,275 @@ +/* + * 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.Attr +import com.tencent.kuikly.core.base.Color +import com.tencent.kuikly.core.base.DeclarativeBaseView +import com.tencent.kuikly.core.base.ViewConst +import com.tencent.kuikly.core.base.ViewContainer +import com.tencent.kuikly.core.base.event.Event +import com.tencent.kuikly.core.base.toInt +import com.tencent.kuikly.core.layout.FlexAlign +import com.tencent.kuikly.core.layout.FlexDirection +import com.tencent.kuikly.core.layout.FlexNode +import com.tencent.kuikly.core.layout.FlexPositionType +import com.tencent.kuikly.core.layout.MeasureFunction +import com.tencent.kuikly.core.layout.MeasureOutput +import com.tencent.kuikly.core.layout.isUndefined +import com.tencent.kuikly.core.views.shadow.TextShadow + +/** + * System-selectable plain text surface. + * + * Renders immutable plain text on each platform's native text view so the OS + * selection experience is available anchored to the selection. The baseline + * guarantee is: word selection, drag handles, Select all and Copy. Any + * further menu actions (for example Translate, Look Up, Share, or Android + * PROCESS_TEXT targets) appear only when the current OS version, locale and + * installed services provide them — they are platform-supplied extras, not + * guarantees of this component. The surface is read-only by construction — + * it never participates in text input, never shows an IME, and exposes no + * way for the user or the program to mutate text except through the + * [SelectableTextAttr.text] prop. + * + * Platform mapping: + * - Android: `TextView` with `setTextIsSelectable(true)` (system ActionMode) + * - iOS: `UITextView` with `editable = NO`, `selectable = YES` (system edit menu) + * - OHOS: ArkUI `Text` node with the system copy option enabled; the copy + * option guarantees local-device copy scope, and the visible menu items are + * whatever the platform's selection menu offers on that OS version + * + * Layout: content is measured with the shared rich-text shadow (same approach + * as [TextAreaView]); scrolling is the caller's responsibility (wrap in a + * scroller for long content). + */ +open class SelectableTextView : DeclarativeBaseView(), MeasureFunction { + + companion object { + private val NON_SHADOW_PROPS by lazy(LazyThreadSafetyMode.NONE) { + setOf( + Attr.StyleConst.TRANSFORM, + Attr.StyleConst.OPACITY, + Attr.StyleConst.VISIBILITY, + Attr.StyleConst.BACKGROUND_COLOR, + TextConst.TEXT_COLOR + ) + } + } + + private var shadow: TextShadow? = null + + override fun willInit() { + super.willInit() + shadow = TextShadow(pagerId, nativeRef, ViewConst.TYPE_RICH_TEXT) + getViewAttr().fontSize(15f) + } + + override fun createAttr(): SelectableTextAttr { + return SelectableTextAttr() + } + + override fun createEvent(): Event { + return Event() + } + + override fun viewName(): String { + return ViewConst.TYPE_SELECTABLE_TEXT + } + + override fun createFlexNode() { + super.createFlexNode() + flexNode.measureFunction = this + } + + override fun didRemoveFromParentView() { + super.didRemoveFromParentView() + flexNode.measureFunction = null + shadow?.removeFromParentComponent() + shadow = null + } + + override fun didSetProp(propKey: String, propValue: Any) { + super.didSetProp(propKey, propValue) + if (propKey !in NON_SHADOW_PROPS) { + shadow?.setProp(propKey, propValue) + flexNode.markDirty() + } + } + + override fun measure( + node: FlexNode, + width: Float, + height: Float, + measureOutput: MeasureOutput + ) { + node.layoutDimensions.run { + if (!this[0].isUndefined() && !this[1].isUndefined()) { + measureOutput.width = this[0] + measureOutput.height = this[1] + return + } + } + val cWidth = if (width.isUndefined()) 100000f else width + val cHeight = if (height.isUndefined()) -1f else height + val size = shadow?.calculateRenderViewSize(cWidth, cHeight) + var outWidth = size?.width ?: 0f + var outHeight = size?.height ?: 0f + if (!width.isUndefined() && outWidth < width && node.stretchWidth()) { + outWidth = width + } + if (!height.isUndefined() && outHeight < height && node.stretchHeight()) { + outHeight = height + } + node.styleMinWidth.also { + if (!it.isUndefined() && outWidth < it) { + outWidth = it + } + } + node.styleMaxHeight.also { + if (!it.isUndefined() && outHeight > it) { + outHeight = it + } + } + node.styleMinHeight.also { + if (!it.isUndefined() && outHeight < it) { + outHeight = it + } + } + measureOutput.width = outWidth + measureOutput.height = outHeight + } + + /** + * Measures the native selectable text through the same [TextShadow] used + * by the core flex path. Compose wrappers call this when their parent uses + * an unbounded main-axis constraint (for example a vertical LazyColumn), + * where the generic native-node measure policy cannot use maxHeight as an + * actual layout dimension. + */ + open fun calculateContentSize(maxWidth: Float, maxHeight: Float) = + shadow?.calculateRenderViewSize(maxWidth, maxHeight) + + private fun FlexNode.stretchWidth(): Boolean { + if (positionType != FlexPositionType.RELATIVE) { + return false + } + val direction = parent?.flexDirection + return if (direction == FlexDirection.ROW || direction == FlexDirection.ROW_REVERSE) { + stretchMainAxis + } else { + parent?.layoutWidth?.isUndefined() == false && stretchCrossAxis + } + } + + private fun FlexNode.stretchHeight(): Boolean { + if (positionType != FlexPositionType.RELATIVE) { + return false + } + val direction = parent?.flexDirection + return if (direction == FlexDirection.ROW || direction == FlexDirection.ROW_REVERSE) { + parent?.layoutHeight?.isUndefined() == false && stretchCrossAxis + } else { + stretchMainAxis + } + } + + private inline val FlexNode.stretchMainAxis: Boolean get() = flex != 0f + + private inline val FlexNode.stretchCrossAxis: Boolean + get() = alignSelf == FlexAlign.STRETCH || + (alignSelf == FlexAlign.AUTO && parent?.alignItems == FlexAlign.STRETCH) +} + +/** + * Attributes for [SelectableTextView]. Prop keys reuse [TextConst] so the + * shared rich-text shadow measures with exactly the same values the native + * view renders. + */ +open class SelectableTextAttr : Attr() { + + open fun text(text: String): SelectableTextAttr { + TextConst.VALUE with text + return this + } + + open fun color(color: Color): SelectableTextAttr { + TextConst.TEXT_COLOR with color.toString() + return this + } + + open fun color(color: Long): SelectableTextAttr { + TextConst.TEXT_COLOR with Color(color).toString() + return this + } + + open fun fontSize(size: Float): SelectableTextAttr { + TextConst.FONT_SIZE with size + return this + } + + open fun fontWeightNormal(): SelectableTextAttr { + TextConst.FONT_WEIGHT with FontWeight.NORMAL.value + return this + } + + open fun fontWeightMedium(): SelectableTextAttr { + TextConst.FONT_WEIGHT with FontWeight.MEDIUM.value + return this + } + + open fun fontWeightSemiBold(): SelectableTextAttr { + TextConst.FONT_WEIGHT with FontWeight.SEMIBOLD.value + return this + } + + open fun fontWeightBold(): SelectableTextAttr { + TextConst.FONT_WEIGHT with FontWeight.BOLD.value + return this + } + + open fun lineHeight(lineHeight: Float): SelectableTextAttr { + TextConst.LINE_HEIGHT with lineHeight + return this + } + + open fun useDpFontSizeDim(useDp: Boolean = true): SelectableTextAttr { + TextConst.TEXT_USE_DP_FONT_SIZE_DIM with useDp.toInt() + return this + } + + open fun textAlignLeft(): SelectableTextAttr { + TextConst.TEXT_ALIGN with "left" + return this + } + + open fun textAlignCenter(): SelectableTextAttr { + TextConst.TEXT_ALIGN with "center" + return this + } + + open fun textAlignRight(): SelectableTextAttr { + TextConst.TEXT_ALIGN with "right" + return this + } +} + +/** + * Adds a system-selectable plain text view. See [SelectableTextView]. + */ +fun ViewContainer<*, *>.SelectableText(init: SelectableTextView.() -> Unit) { + addChild(SelectableTextView(), init) +} 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..f1195407d 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 @@ -717,7 +722,14 @@ 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 = text, + focusRequestId = focusRequestId, + focusIntentOnly = it.optBoolean("focusIntentOnly"), + ), + ) } } /** @@ -728,7 +740,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)) } } 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..f3f6a7817 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,7 @@ 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, ) { fun toJSONObject(): JSONObject { return JSONObject().apply { @@ -92,7 +92,7 @@ data class TextInputState( selectionEnd = selectionEnd, compositionStart = compositionStart, compositionEnd = compositionEnd, - length = length + length = length, ) } } 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..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 @@ -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" @@ -553,6 +571,18 @@ 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 SLOCK_INLINE_CODE_TRAILING_MARGIN = "slockInlineCodeTrailingMargin" + 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" @@ -590,4 +620,4 @@ fun ViewContainer<*, *>.Text(init: TextView.() -> Unit) { } else { addChild(TextView(), init) } -} \ No newline at end of file +} 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..80fde5193 100644 --- a/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def +++ b/core/src/ohosArm64Main/ohosInterop/cinterop/ohos.def @@ -335,9 +335,10 @@ 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 void com_tencent_kuikly_ScheduleContextIdleTask(const char* pagerId, void (*onSchedule)(const char* pagerId)); extern bool com_tencent_kuikly_IsCurrentOnContextThread(const char* pagerId); long long com_tencent_kuikly_GetThreadCPUTimeInNanoseconds() { @@ -346,4 +347,4 @@ long long com_tencent_kuikly_GetThreadCPUTimeInNanoseconds() { return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; } return 0; -} \ No newline at end of file +} 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/base/BridgeModule.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/base/BridgeModule.kt index 71aa49aea..4572d239f 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/base/BridgeModule.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/base/BridgeModule.kt @@ -33,6 +33,20 @@ internal class BridgeModule : Module() { callNativeMethod("toast", methodArgs, null) } + fun requestLandscape() { + requestOrientation("landscape") + } + + fun requestPortrait() { + requestOrientation("portrait") + } + + private fun requestOrientation(orientation: String) { + val methodArgs = JSONObject() + methodArgs.put("orientation", orientation) + callNativeMethod(REQUEST_ORIENTATION, methodArgs, null) + } + fun testArray() { //call val array = arrayOf("222", createByteArray()) @@ -188,6 +202,7 @@ internal class BridgeModule : Module() { const val KEY_FEED_PB_TOKEN = "feedPbToken" const val SET_STATUS_BAR_WHITE = "setWhiteStatusBarStyle" const val SET_STATUS_BAR_BLACK = "setBlackStatusBarStyle" + const val REQUEST_ORIENTATION = "requestOrientation" const val GET_CURRENT_ACCOUNT = "getAccount" const val DOWNLOAD_PAG_SO = "downloadPagSo" const val GET_LOCAL_IMAGE_PATH = "getLocalImagePath" 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 5d0dec103..0ea25954b 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 @@ -110,9 +110,10 @@ 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("转屏调试", "全屏蒙层+蓝色方块,横竖屏切换", "ComposeOrientationOverlayDemo"), + DemoItem("视频横竖屏", "MovableContent视频横竖屏切换示例", "ComposeVideoOrientationDemo"), DemoItem("iOS LiquidGlass", "iOS LiquidGlass 组件示例", "LiquidGlassComposeDemo"), // 动画 @@ -168,6 +169,7 @@ internal class ComposeAllSample : ComposeContainer() { DemoItem("重组性能分析", "RecompositionProfiler追踪重组热点", "RecompositionProfilerDemo"), DemoItem("TextFieldEmoji", "TextField 自定义表情示例(暂不支持鸿蒙)", "TextFieldEmojiDemo"), DemoItem("MoveableDrawer", "侧边栏组件示例(全屏/非全屏)", "MoveableDrawerDemo"), + DemoItem("iOS键盘InputTextField", "业务侧 InputTextField iOS 键盘复现", "IosKeyboardInputTextFieldDemo"), ) @Composable @@ -287,4 +289,4 @@ internal class ComposeAllSample : ComposeContainer() { @Composable fun NavBar(title: String) { -} \ No newline at end of file +} diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeOrientationOverlayDemo.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeOrientationOverlayDemo.kt new file mode 100644 index 000000000..70b44ca3b --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeOrientationOverlayDemo.kt @@ -0,0 +1,470 @@ +/* + * 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 com.tencent.kuikly.compose.ComposeContainer +import com.tencent.kuikly.compose.animation.core.Animatable +import com.tencent.kuikly.compose.animation.core.tween +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.BoxWithConstraints +import com.tencent.kuikly.compose.foundation.layout.Column +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.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.graphics.Color +import com.tencent.kuikly.compose.ui.platform.LocalActivity +import com.tencent.kuikly.compose.ui.text.font.FontWeight +import com.tencent.kuikly.compose.ui.unit.Dp +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.module.Module +import com.tencent.kuikly.core.pager.Pager +import com.tencent.kuikly.demo.pages.base.BridgeModule +import kotlinx.coroutines.delay + +private const val VIDEO_EXPAND_ANIM_MS = 360 + +/** 返回竖屏时先保留蒙层,requestPortrait 后再卸掉 */ +private const val PORTRAIT_HOLD_MS = 100L + +/** 与 ComposeVideoOrientationDemo 一致:竖屏列表顶栏高度 */ +private val PortraitVideoHeaderHeight = 220.dp + +/** 返回竖屏时冻结蒙层横屏尺寸,避免 L4 在容器仍横时先缩成竖屏触发错误居中 */ +private data class OrientationFrozenLayout( + val windowWidth: Dp, + val windowHeight: Dp, + val videoWidth: Dp, + val videoHeight: Dp, +) + +/** 调试用外框:Kuikly Compose 无 border Modifier,用 padding 模拟粗边框 */ +@Composable +private fun DebugFrame( + color: Color, + thickness: Dp = 8.dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .background(color) + .padding(thickness), + ) { + content() + } +} + +private fun lerpDp(start: Dp, end: Dp, fraction: Float): Dp = start + (end - start) * fraction + +/** 横屏全屏:长边撑满 16:9 */ +private fun landscapeFullscreenSize(windowWidth: Dp, windowHeight: Dp): Pair { + return if (windowWidth >= windowHeight) { + windowWidth to (windowWidth * 9f / 16f) + } else { + (windowHeight * 16f / 9f) to windowHeight + } +} + +/** 蒙层竖屏初始尺寸:宽 = 窗口窄边对应的全宽,高 = 顶栏 220dp */ +private fun portraitOverlayVideoSize(windowWidth: Dp, windowHeight: Dp): Pair { + val width = if (windowWidth < windowHeight) windowWidth else windowHeight + return width to PortraitVideoHeaderHeight +} + +@Page("ComposeOrientationOverlayDemo") +internal class ComposeOrientationOverlayDemo : ComposeContainer() { + override fun createExternalModules(): Map? { + return mapOf(BridgeModule.MODULE_NAME to BridgeModule()) + } + + override fun willInit() { + super.willInit() + setContent { + ComposeOrientationOverlayDemoContent() + } + } +} + +@Composable +private fun ComposeOrientationOverlayDemoContent() { + val pager = LocalActivity.current.getPager() as Pager + val bridgeModule = pager.acquireModule(BridgeModule.MODULE_NAME) + var showOverlay by remember { mutableStateOf(false) } + var shouldRequestPortrait by remember { mutableStateOf(false) } + var frozenOverlayLayout by remember { mutableStateOf(null) } + /** 点横屏后锁定竖屏蒙层尺寸,直到窗口约束真正变横屏,避免旋转中间态坐标乱跳 */ + var enterPortraitLock by remember { mutableStateOf(null) } + var statusLabel by remember { mutableStateOf("竖屏 · 列表顶栏") } + + // 返回时:冻结横屏蒙层 → requestPortrait → 短暂保留再卸掉,避免 L4 提前缩竖屏。 + LaunchedEffect(shouldRequestPortrait) { + if (shouldRequestPortrait && showOverlay) { + bridgeModule.requestPortrait() + delay(PORTRAIT_HOLD_MS) + showOverlay = false + frozenOverlayLayout = null + enterPortraitLock = null + shouldRequestPortrait = false + statusLabel = "竖屏 · 列表顶栏" + } + } + + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val windowW = maxWidth + val windowH = maxHeight + val isLandscape = windowW > windowH + val portraitWidth = if (maxWidth < maxHeight) maxWidth else maxHeight + val portraitHeight = if (maxWidth < maxHeight) maxHeight else maxWidth + + LaunchedEffect(isLandscape) { + if (isLandscape) { + enterPortraitLock = null + } + } + + val activePortraitLock = enterPortraitLock?.takeUnless { isLandscape } + val activeFrozenLayout = frozenOverlayLayout + + // 蒙层期间 L5 根始终铺满窗口;竖屏锁/冻结尺寸只交给蒙层内部处理,避免 L5 在横屏窗里被裁成竖条看不见蒙层 + val composeRootAlign = if (showOverlay) Alignment.Center else Alignment.TopStart + val composeRootModifier = if (showOverlay) { + Modifier.fillMaxSize() + } else { + Modifier.size(portraitWidth, portraitHeight) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + ) { + DebugFrame( + color = Color(0xFFFF00FF), + modifier = composeRootModifier.align(composeRootAlign), + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (!showOverlay) { + DebugFrame( + color = Color(0xFF00C853), + modifier = Modifier.fillMaxSize(), + ) { + PortraitMockPage( + onEnterLandscape = { + val (videoW, videoH) = portraitOverlayVideoSize(windowW, windowH) + enterPortraitLock = OrientationFrozenLayout( + windowWidth = windowW, + windowHeight = windowH, + videoWidth = videoW, + videoHeight = videoH, + ) + frozenOverlayLayout = null + bridgeModule.requestLandscape() + showOverlay = true + statusLabel = "横屏 · 等待转屏" + }, + ) + } + } + + if (showOverlay) { + PhoneStyleFullscreenOverlay( + isLandscape = isLandscape, + portraitLock = activePortraitLock, + frozenLayout = activeFrozenLayout, + onBack = { + val (videoWidth, videoHeight) = landscapeFullscreenSize(windowW, windowH) + frozenOverlayLayout = OrientationFrozenLayout( + windowWidth = windowW, + windowHeight = windowH, + videoWidth = videoWidth, + videoHeight = videoHeight, + ) + enterPortraitLock = null + shouldRequestPortrait = true + statusLabel = "竖屏 · 返回中" + }, + ) + } + + if (!showOverlay) { + OrientationDebugPanel( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp), + statusLabel = statusLabel, + windowW = windowW, + windowH = windowH, + lockLabel = "无锁", + compact = false, + ) + } + } + } + } + } +} + +@Composable +private fun OrientationDebugPanel( + statusLabel: String, + windowW: Dp, + windowH: Dp, + lockLabel: String, + compact: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = if (compact) Alignment.End else Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (!compact) { + Text( + text = "Compose 转屏调试", + color = Color(0xFF111111), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = statusLabel, + color = Color(0xFF1565C0), + fontSize = if (compact) 11.sp else 12.sp, + ) + Text( + text = "窗口 ${windowW.value.toInt()} x ${windowH.value.toInt()} · $lockLabel", + color = Color(0xFF78909C), + fontSize = if (compact) 9.sp else 10.sp, + ) + Text( + text = if (compact) { + "红Native 紫Compose 黄蒙层" + } else { + "红框=L4 Native | 紫框=L5 Compose根 | 绿框=列表 | 黄框=蒙层" + }, + color = Color(0xFF78909C), + fontSize = 9.sp, + ) + } +} + +/** 模拟竖屏视频+评论页:顶栏黑色蒙层里放横条视频 */ +@Composable +private fun PortraitMockPage( + onEnterLandscape: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F6FA)), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PortraitVideoHeaderHeight) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + MockVideoRect( + modifier = Modifier.fillMaxSize(), + label = "模拟视频", + subLabel = "竖屏顶栏横条", + ) + OrientationActionButton( + label = "横屏", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + onClick = onEnterLandscape, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(Color.White) + .padding(16.dp), + ) { + Text( + text = "视频评论", + color = Color(0xFF111111), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = "竖屏:顶栏横条在黑色蒙层上;点横屏后全屏黑蒙层,矩形居中再放大到全屏。", + color = Color(0xFF666666), + fontSize = 13.sp, + modifier = Modifier.padding(top = 8.dp), + ) + repeat(6) { index -> + Text( + text = "评论 #${index + 1}:转屏调试占位内容", + color = Color(0xFF333333), + fontSize = 14.sp, + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } +} + +/** + * 全屏蒙层:父级 L5 根已锁定尺寸;此处 fillMaxSize 铺满父容器即可。 + */ +@Composable +private fun PhoneStyleFullscreenOverlay( + isLandscape: Boolean, + portraitLock: OrientationFrozenLayout? = null, + frozenLayout: OrientationFrozenLayout? = null, + onBack: () -> Unit, +) { + val videoProgress = remember { Animatable(if (frozenLayout != null) 1f else 0f) } + val layoutFrozen = frozenLayout != null + val portraitLocked = portraitLock != null && !layoutFrozen + + LaunchedEffect(layoutFrozen, portraitLocked, isLandscape) { + if (layoutFrozen || portraitLocked) return@LaunchedEffect + if (isLandscape) { + videoProgress.animateTo(1f, tween(VIDEO_EXPAND_ANIM_MS)) + } else { + videoProgress.snapTo(0f) + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + ) { + val windowW = maxWidth + val windowH = maxHeight + val progress = if (layoutFrozen) 1f else videoProgress.value.coerceIn(0f, 1f) + val (initialW, initialH) = if (portraitLocked) { + portraitLock.videoWidth to portraitLock.videoHeight + } else { + portraitOverlayVideoSize(windowW, windowH) + } + val (targetW, targetH) = if (layoutFrozen) { + frozenLayout.videoWidth to frozenLayout.videoHeight + } else { + landscapeFullscreenSize(windowW, windowH) + } + val videoW = if (layoutFrozen) targetW else lerpDp(initialW, targetW, progress) + val videoH = if (layoutFrozen) targetH else lerpDp(initialH, targetH, progress) + + DebugFrame( + color = Color(0xFFFFEB3B), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + MockVideoRect( + modifier = Modifier.size(videoW, videoH), + label = "模拟视频", + subLabel = when { + layoutFrozen -> "返回竖屏 · 冻结横屏" + portraitLocked -> "竖屏锁 ${windowW.value.toInt()}x${windowH.value.toInt()}" + !isLandscape -> "竖屏横条 · 等待转屏" + progress < 1f -> "横屏 · 放大中" + else -> "横屏全屏" + }, + ) + OrientationActionButton( + label = "竖屏", + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 16.dp, top = 32.dp), + onClick = onBack, + ) + } + } + } +} + +@Composable +private fun MockVideoRect( + modifier: Modifier = Modifier, + label: String, + subLabel: String, +) { + Box( + modifier = modifier.background(Color(0xFF2196F3)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = label, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = subLabel, + color = Color(0xFFE3F2FD), + fontSize = 12.sp, + modifier = Modifier.padding(top = 6.dp), + ) + } + } +} + +@Composable +private fun OrientationActionButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(Color(0xCCFFFFFF), RoundedCornerShape(22.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = Color(0xFF111111), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + } +} diff --git a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeVideoOrientationDemo.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeVideoOrientationDemo.kt new file mode 100644 index 000000000..551e21124 --- /dev/null +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/compose/ComposeVideoOrientationDemo.kt @@ -0,0 +1,752 @@ +/* + * 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.movableContentOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.tencent.kuikly.compose.ComposeContainer +import com.tencent.kuikly.compose.animation.core.Animatable +import com.tencent.kuikly.compose.animation.core.tween +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.BoxWithConstraints +import com.tencent.kuikly.compose.foundation.layout.Column +import com.tencent.kuikly.compose.foundation.layout.PaddingValues +import com.tencent.kuikly.compose.foundation.layout.Row +import com.tencent.kuikly.compose.foundation.layout.Spacer +import com.tencent.kuikly.compose.foundation.layout.fillMaxHeight +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.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.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.graphics.Color +import com.tencent.kuikly.compose.ui.platform.LocalActivity +import com.tencent.kuikly.compose.ui.text.font.FontWeight +import com.tencent.kuikly.compose.ui.text.style.TextOverflow +import com.tencent.kuikly.compose.ui.unit.Dp +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.module.Module +import com.tencent.kuikly.core.pager.Pager +import com.tencent.kuikly.core.views.PlayState +import com.tencent.kuikly.core.views.VideoPlayControl +import com.tencent.kuikly.core.nvi.serialization.json.JSONObject +import com.tencent.kuikly.demo.pages.base.BridgeModule +import kotlinx.coroutines.delay + +/** 折叠屏展开态最小宽度阈值:宽于此值认为处于展开态 */ +private val FoldableExpandedMinWidth = 600.dp + +/** 折叠屏展开态右侧推荐视频列宽度 */ +private val FoldableRecommendColumnWidth = 280.dp + +/** 列表预渲染数量调大,避免快速横竖屏切换后底部露出半截空白。 */ +private const val CommentBeyondBoundsItemCount = 30 + +private const val VIDEO_DEBUG_TAG = "BD_VideoOrientation" + +/** 点击横屏后,视频从原始尺寸放到当前窗口长边撑满的动画时间。 */ +private const val VIDEO_EXPAND_ANIM_MS = 360 + +/** 返回竖屏兜底超时:尺寸回调异常时强制卸蒙层。 */ +private const val PORTRAIT_DISMISS_TIMEOUT_MS = 1200L + +/** 顶部播放器在竖屏列表中的高度。 */ +private val PortraitVideoHeaderHeight = 220.dp + +private fun videoDebugLog(message: String) { + println("[$VIDEO_DEBUG_TAG] $message") +} + +private fun lerpDp(start: Dp, end: Dp, fraction: Float): Dp = start + (end - start) * fraction + +private data class FrozenOverlayLayout( + val windowWidth: Dp, + val windowHeight: Dp, + val videoWidth: Dp, + val videoHeight: Dp, +) + +private fun targetVideoSize(windowWidth: Dp, windowHeight: Dp): Pair { + return if (windowWidth >= windowHeight) { + windowWidth to (windowWidth * 9f / 16f) + } else { + (windowHeight * 16f / 9f) to windowHeight + } +} + +private data class RecommendedVideo( + val id: Int, + val title: String, + val duration: String, +) + +@Page("ComposeVideoOrientationDemo") +internal class ComposeVideoOrientationDemo : ComposeContainer() { + override fun createExternalModules(): Map? { + val externalModules = hashMapOf() + externalModules[BridgeModule.MODULE_NAME] = BridgeModule() + return externalModules + } + + override fun willInit() { + super.willInit() + setContent { + ComposeVideoOrientationDemoContent() + } + } +} + +@Composable +private fun ComposeVideoOrientationDemoContent() { + val pager = LocalActivity.current.getPager() as Pager + val bridgeModule = pager.acquireModule(BridgeModule.MODULE_NAME) + var showFullscreenOverlay by remember { mutableStateOf(false) } + var shouldRequestPortrait by remember { mutableStateOf(false) } + var frozenOverlayLayout by remember { mutableStateOf(null) } + var frozenFoldableExpanded by remember { mutableStateOf(null) } + var playControl by remember { mutableStateOf(VideoPlayControl.PLAY) } + var playResumeCount by remember { mutableIntStateOf(0) } + var videoDebugStatus by remember { mutableStateOf("init") } + var videoPlayTimeSec by remember { mutableIntStateOf(0) } + val comments = remember { (1..30).toList() } + val recommendedVideos = remember { buildRecommendedVideos() } + // 低码率短视频,弱网下比原 oceans 视频更容易稳定播放。 + val videoUrl = "https://www.w3schools.com/html/mov_bbb.mp4" + + val movableVideo = remember { + movableContentOf { modifier: Modifier -> + MovableVideoPlayer( + videoUrl = videoUrl, + playControl = playControl, + modifier = modifier, + onPlayStateChanged = { state, extInfo -> + val detail = extInfo.toString() + videoDebugStatus = when (state) { + PlayState.PLAY_END -> "PLAY_END(已开启循环,应自动重播)" + else -> "${state.name} | $detail" + } + videoDebugLog("playState=${state.name} ext=$detail playControl=$playControl") + }, + onPlayTimeChanged = { curTime, totalTime -> + videoPlayTimeSec = curTime / 1000 + if (curTime % 5000 < 200) { + videoDebugLog("playTime=${curTime}ms total=${totalTime}ms") + } + }, + ) + } + } + + // 全屏切换后 Surface 可能解绑,延迟恢复播放。 + LaunchedEffect(showFullscreenOverlay) { + videoDebugLog("showFullscreenOverlay -> $showFullscreenOverlay") + delay(if (showFullscreenOverlay) 80 else 0) + playResumeCount++ + playControl = VideoPlayControl.PLAY + videoDebugLog("resumePlay #$playResumeCount playControl=PLAY overlay=$showFullscreenOverlay") + } + + // 返回:先 requestPortrait;蒙层不冻结尺寸,跟窗口 fillMaxSize。 + // 窗口真正回到竖屏后再卸蒙层,避免竖屏上残留 800x360 横屏黑块。 + LaunchedEffect(shouldRequestPortrait) { + if (shouldRequestPortrait && showFullscreenOverlay) { + videoDebugLog("exit: requestPortrait first, keep fillMaxSize overlay") + bridgeModule.requestPortrait() + delay(PORTRAIT_DISMISS_TIMEOUT_MS) + if (shouldRequestPortrait && showFullscreenOverlay) { + videoDebugLog("exit: portrait dismiss timeout, force dismiss") + showFullscreenOverlay = false + frozenOverlayLayout = null + frozenFoldableExpanded = null + shouldRequestPortrait = false + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val portraitWidth = if (maxWidth < maxHeight) maxWidth else maxHeight + val portraitHeight = if (maxWidth < maxHeight) maxHeight else maxWidth + val isLandscape = maxWidth > maxHeight + // 用当前窗口宽度判断展开态,比 portraitWidth 更快响应折叠屏展开。 + val isFoldableExpanded = frozenFoldableExpanded + ?: (maxWidth >= FoldableExpandedMinWidth) + + // 竖屏到位:立刻卸蒙层(此时蒙层已是竖屏全屏,不会残留横屏尺寸块)。 + LaunchedEffect(shouldRequestPortrait, isLandscape, showFullscreenOverlay) { + if (shouldRequestPortrait && showFullscreenOverlay && !isLandscape) { + videoDebugLog("exit: portrait settled ${maxWidth}x$maxHeight, dismiss overlay") + showFullscreenOverlay = false + frozenOverlayLayout = null + frozenFoldableExpanded = null + shouldRequestPortrait = false + } + } + + val pageModifier = if (isFoldableExpanded && !showFullscreenOverlay) { + Modifier.fillMaxSize() + } else { + Modifier + .width(portraitWidth) + .height(portraitHeight) + .align(Alignment.TopStart) + } + + LaunchedEffect(portraitWidth, maxWidth, maxHeight, showFullscreenOverlay, isFoldableExpanded) { + videoDebugLog( + "layout overlay=$showFullscreenOverlay constraints=${maxWidth}x$maxHeight " + + "portrait=${portraitWidth}x$portraitHeight foldableExpanded=$isFoldableExpanded", + ) + } + + Box( + modifier = pageModifier + .background(Color(0xFFF5F6FA)), + ) { + PortraitVideoCommentPage( + comments = comments, + isFoldableExpanded = isFoldableExpanded, + recommendedVideos = recommendedVideos, + movableVideo = movableVideo, + videoInOverlay = showFullscreenOverlay, + videoDebugStatus = videoDebugStatus, + videoPlayTimeSec = videoPlayTimeSec, + playResumeCount = playResumeCount, + onEnterFullscreen = { + videoDebugLog("enterFullscreen foldableExpanded=$isFoldableExpanded") + frozenOverlayLayout = null + frozenFoldableExpanded = isFoldableExpanded + if (!isFoldableExpanded) { + videoDebugLog("requestLandscape immediately on enter") + bridgeModule.requestLandscape() + } + showFullscreenOverlay = true + }, + ) + } + + if (showFullscreenOverlay) { + if (isFoldableExpanded) { + FoldableFullscreenOverlay( + movableVideo = movableVideo, + onBack = { + videoDebugLog("exitFullscreen foldable") + showFullscreenOverlay = false + frozenFoldableExpanded = null + }, + ) + } else { + // 退出过程中不冻结旧横屏尺寸:蒙层/视频都跟当前窗口走,竖屏到位后再卸。 + val exiting = shouldRequestPortrait + val (exitVideoW, exitVideoH) = targetVideoSize(maxWidth, maxHeight) + PhoneFullscreenHoldOverlay( + modifier = Modifier.fillMaxSize(), + windowWidth = maxWidth, + windowHeight = maxHeight, + frozenLayout = if (exiting) { + FrozenOverlayLayout( + windowWidth = maxWidth, + windowHeight = maxHeight, + videoWidth = exitVideoW, + videoHeight = exitVideoH, + ) + } else { + null + }, + movableVideo = movableVideo, + onBack = { + videoDebugLog("exitFullscreen phone: requestPortrait, wait portrait then dismiss") + shouldRequestPortrait = true + }, + ) + } + } + } + } +} + +@Composable +private fun PhoneFullscreenHoldOverlay( + modifier: Modifier = Modifier, + windowWidth: Dp, + windowHeight: Dp, + frozenLayout: FrozenOverlayLayout? = null, + movableVideo: @Composable (Modifier) -> Unit, + onBack: () -> Unit, +) { + val videoProgress = remember { Animatable(if (frozenLayout != null) 1f else 0f) } + val layoutFrozen = frozenLayout != null + + LaunchedEffect(layoutFrozen) { + if (layoutFrozen) return@LaunchedEffect + videoDebugLog("overlay: expand video (landscape already requested)") + videoProgress.animateTo(1f, tween(VIDEO_EXPAND_ANIM_MS)) + } + + val progress = if (layoutFrozen) 1f else videoProgress.value.coerceIn(0f, 1f) + val initialVideoWidth = if (windowWidth < windowHeight) windowWidth else windowHeight + val initialVideoHeight = PortraitVideoHeaderHeight + val (targetVideoWidth, targetVideoHeight) = if (layoutFrozen) { + frozenLayout.videoWidth to frozenLayout.videoHeight + } else { + targetVideoSize(windowWidth, windowHeight) + } + val videoWidth = if (layoutFrozen) targetVideoWidth else lerpDp(initialVideoWidth, targetVideoWidth, progress) + val videoHeight = if (layoutFrozen) targetVideoHeight else lerpDp(initialVideoHeight, targetVideoHeight, progress) + + // 外层铺满当前窗口挡背景;退出冻结时内层保持点击返回时的横屏尺寸。 + Box( + modifier = modifier.background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(windowWidth, windowHeight) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + movableVideo( + Modifier.size(videoWidth, videoHeight), + ) + BackButton( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 16.dp, top = 32.dp), + onClick = onBack, + ) + } + } +} + +@Composable +private fun FoldableFullscreenOverlay( + movableVideo: @Composable (Modifier) -> Unit, + onBack: () -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + ) { + val videoHeight = maxWidth * 9f / 16f + movableVideo( + Modifier + .fillMaxWidth() + .height(videoHeight) + .align(Alignment.Center), + ) + BackButton( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 16.dp, top = 32.dp), + onClick = onBack, + ) + } +} + +@Composable +private fun MovableVideoPlayer( + videoUrl: String, + playControl: VideoPlayControl, + modifier: Modifier = Modifier, + onPlayStateChanged: ((PlayState, JSONObject) -> Unit)? = null, + onPlayTimeChanged: ((Int, Int) -> Unit)? = null, +) { + Video( + src = videoUrl, + playControl = playControl, + modifier = modifier.background(Color.Black), + onPlayStateChanged = onPlayStateChanged, + onPlayTimeChanged = onPlayTimeChanged, + ) +} + +@Composable +private fun VideoDebugPanel( + status: String, + playTimeSec: Int, + playResumeCount: Int, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xCC111111)) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("视频诊断", color = Color(0xFFFFD54F), fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text("状态: $status", color = Color.White, fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp)) + Text( + "进度: ${playTimeSec}s | resume次数: $playResumeCount", + color = Color(0xFFB0BEC5), + fontSize = 11.sp, + modifier = Modifier.padding(top = 2.dp), + ) + Text( + "日志前缀: $VIDEO_DEBUG_TAG", + color = Color(0xFF78909C), + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +@Composable +private fun PortraitVideoCommentPage( + comments: List, + isFoldableExpanded: Boolean, + recommendedVideos: List, + movableVideo: @Composable (Modifier) -> Unit, + videoInOverlay: Boolean, + videoDebugStatus: String, + videoPlayTimeSec: Int, + playResumeCount: Int, + onEnterFullscreen: () -> Unit, +) { + if (isFoldableExpanded) { + // 折叠屏展开态:左侧视频+评论列表,右侧推荐视频占满整页高度。 + Row(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentPadding = PaddingValues(bottom = 24.dp), + beyondBoundsItemCount = CommentBeyondBoundsItemCount, + ) { + item { + FoldableVideoHeader( + movableVideo = movableVideo, + videoInOverlay = videoInOverlay, + onEnterFullscreen = onEnterFullscreen, + ) + } + + item { + VideoDebugPanel( + status = videoDebugStatus, + playTimeSec = videoPlayTimeSec, + playResumeCount = playResumeCount, + ) + } + + item { + VideoCommentTitle(isFoldableExpanded = true) + } + + items(comments) { index -> + CommentRow(index) + } + } + + RecommendedVideoColumn( + modifier = Modifier + .width(FoldableRecommendColumnWidth) + .fillMaxHeight(), + videos = recommendedVideos, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + beyondBoundsItemCount = CommentBeyondBoundsItemCount, + ) { + item { + PhoneVideoHeader( + movableVideo = movableVideo, + videoInOverlay = videoInOverlay, + onEnterFullscreen = onEnterFullscreen, + ) + } + + item { + VideoDebugPanel( + status = videoDebugStatus, + playTimeSec = videoPlayTimeSec, + playResumeCount = playResumeCount, + ) + } + + item { + VideoCommentTitle(isFoldableExpanded = false) + } + + items(comments) { index -> + CommentRow(index) + } + } + } +} + +@Composable +private fun VideoCommentTitle(isFoldableExpanded: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + text = "视频评论", + color = Color(0xFF111111), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = if (isFoldableExpanded) { + "折叠屏展开:右侧推荐视频占满列表高度;全屏时黑色蒙层盖住页面。" + } else { + "普通手机:点横屏立刻 requestLandscape,蒙层与视频放大并行;返回时蒙层短暂保留防跳变。" + }, + color = Color(0xFF666666), + fontSize = 13.sp, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +@Composable +private fun PhoneVideoHeader( + movableVideo: @Composable (Modifier) -> Unit, + videoInOverlay: Boolean, + onEnterFullscreen: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .background(Color.Black), + ) { + if (!videoInOverlay) { + movableVideo(Modifier.fillMaxSize()) + } + FullscreenButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + label = "横屏", + onClick = onEnterFullscreen, + ) + } +} + +@Composable +private fun FoldableVideoHeader( + movableVideo: @Composable (Modifier) -> Unit, + videoInOverlay: Boolean, + onEnterFullscreen: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .background(Color.Black), + ) { + if (!videoInOverlay) { + movableVideo(Modifier.fillMaxSize()) + } + FullscreenButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + label = "全屏", + onClick = onEnterFullscreen, + ) + } +} + +@Composable +private fun RecommendedVideoColumn( + modifier: Modifier = Modifier, + videos: List, +) { + Column( + modifier = modifier + .background(Color(0xFF1A1A1A)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text( + text = "推荐视频", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + beyondBoundsItemCount = 6, + ) { + items(videos, key = { it.id }) { video -> + RecommendedVideoRow(video) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun RecommendedVideoRow(video: RecommendedVideo) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF2A2A2A), RoundedCornerShape(8.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(width = 72.dp, height = 48.dp) + .background(commentAvatarColor(video.id), RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + Text("▶", color = Color.White, fontSize = 16.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = video.title, + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = video.duration, + color = Color(0xFFAAAAAA), + fontSize = 11.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun FullscreenButton( + modifier: Modifier = Modifier, + label: String, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .background(Color(0xCC000000), RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun BackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .background(Color(0xCC000000), RoundedCornerShape(20.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text("< 返回", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun CommentRow(index: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(42.dp) + .background( + color = commentAvatarColor(index), + shape = RoundedCornerShape(21.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text("$index", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "用户 $index", + color = Color(0xFF222222), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = "这是第 $index 条评论,滑动列表时顶部播放器保持在列表头部。", + color = Color(0xFF666666), + fontSize = 13.sp, + ) + } + } +} + +private fun buildRecommendedVideos(): List { + return listOf( + RecommendedVideo(1, "深海探险纪实", "12:34"), + RecommendedVideo(2, "城市夜景延时", "08:21"), + RecommendedVideo(3, "极限运动集锦", "15:06"), + RecommendedVideo(4, "自然风光 4K", "22:18"), + RecommendedVideo(5, "美食制作教程", "06:45"), + RecommendedVideo(6, "科技产品评测", "11:02"), + RecommendedVideo(7, "旅行 Vlog 精选", "18:37"), + RecommendedVideo(8, "音乐现场 Live", "09:58"), + ) +} + +private fun commentAvatarColor(index: Int): Color { + val colors = listOf( + Color(0xFF4E7CF6), + Color(0xFF26A69A), + Color(0xFFFF8A00), + Color(0xFFAB47BC), + Color(0xFFE53935), + ) + return colors[(index - 1) % colors.size] +} 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 }, 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/DragItemListDemoPage.kt b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/DragItemListDemoPage.kt index b506a7c76..4b0a3557f 100644 --- a/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/DragItemListDemoPage.kt +++ b/demo/src/commonMain/kotlin/com/tencent/kuikly/demo/pages/demo/DragItemListDemoPage.kt @@ -28,7 +28,7 @@ import com.tencent.kuikly.core.base.ViewBuilder import com.tencent.kuikly.core.base.ViewContainer import com.tencent.kuikly.core.base.ViewRef import com.tencent.kuikly.core.base.event.EventHandlerFn -import com.tencent.kuikly.core.base.event.PanGestureParams +import com.tencent.kuikly.core.base.event.LongPressParams import com.tencent.kuikly.core.directives.vforIndex import com.tencent.kuikly.core.log.KLog import com.tencent.kuikly.core.reactive.handler.observable @@ -42,7 +42,7 @@ import kotlin.math.roundToInt /** * 可托拽调整Item顺序的列表Demo -*/ + */ @Page("DragItemListDemoPage") internal class DragItemListDemoPage : BasePager() { var list by observableList() @@ -91,7 +91,7 @@ internal class DragItemListDemoPage : BasePager() { event { editBtnPan { - val params = it as PanGestureParams + val params = it as LongPressParams if (it.pageY < 30f) { val listView = ctx.listRef.view!! val currentOffset = listView.contentView!!.offsetY @@ -324,7 +324,7 @@ internal class DragItemCardView : ComposeView(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/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 { 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)。 diff --git a/docs/API/components/selectable-text.md b/docs/API/components/selectable-text.md new file mode 100644 index 000000000..cf95a50e8 --- /dev/null +++ b/docs/API/components/selectable-text.md @@ -0,0 +1,64 @@ +# SelectableText(系统可选择纯文本) + +只读纯文本组件,使用各平台**系统原生文本视图**渲染,因此长按/选择时出现的是系统选择菜单,菜单锚定在选区旁。 + +**能力边界(重要)**:本组件的基线保证是**选词、拖动选择柄、全选、复制**。其余菜单动作(如翻译、查询、分享;Android 上的 PROCESS_TEXT 目标是一个例子)**由系统按当前 OS 版本、语言区域与已安装服务决定是否出现**,属于平台附赠能力,不是本组件的承诺;不同平台、不同系统版本出现的项可能不同(例如 HarmonyOS 的 copyOption 只保证本机复制范围,不能由此推断出现翻译/分享)。 + +与 `Text`(自绘富文本)的区别:`SelectableText` 不支持富文本/Span,但换来系统原生的选择交互;它**永远只读**——不会弹出输入法,用户与程序都无法通过交互修改文本,文本只能通过 `text` 属性更新。 + +平台实现: + +| 平台 | 实现 | +|:----|:----| +| Android | `TextView.setTextIsSelectable(true)`(系统 ActionMode 菜单) | +| iOS | `UITextView`,`editable=false`、`selectable=true`(系统 Edit Menu) | +| HarmonyOS | ArkUI `Text` 节点开启系统 copyOption=LOCAL_DEVICE(原生选择/复制菜单) | + +滚动不内建:长文本请自行包裹在滚动容器中。 + +## 属性 + +支持所有[基础属性](basic-attr-event.md#基础属性),以及: + +

+ +| 方法 | 描述 | 参数类型 | +|:----|:-------|:--| +| text | 文本内容 | String | +| color | 文字颜色 | Color / Long | +| fontSize | 字体大小 | Float | +| fontWeightNormal / fontWeightMedium / fontWeightSemiBold / fontWeightBold | 字重 | - | +| lineHeight | 行高 | Float | +| textAlignLeft / textAlignCenter / textAlignRight | 对齐 | - | +| useDpFontSizeDim | 字体大小使用 dp 单位(不跟随系统字体缩放) | Boolean | + +
+ +:::tabs + +@tab:active 示例 + +```kotlin +SelectableText { + attr { + text("可以长按选择并使用系统菜单的文本") + fontSize(16f) + color(Color.BLACK) + lineHeight(24f) + } +} +``` + +::: + +## Compose API + +```kotlin +SelectableText( + text = message.body, + modifier = Modifier.fillMaxWidth(), + style = TextStyle(fontSize = 16.sp, lineHeight = 24.sp) +) +``` + +`style` 支持 color、fontSize、fontWeight、lineHeight、textAlign;其余字段在该最小 surface 上忽略。未指定的字段会解析为确定性默认值(黑色、15f、400、fontSize×4/3、left)——由于 Compose 节点可复用,每次更新都会主动写入全部字段,保证样式从「已指定」切回「默认」时旧值被重置。 diff --git a/docs/Compose/list-and-scroll.md b/docs/Compose/list-and-scroll.md index eb399c801..2c150ed73 100644 --- a/docs/Compose/list-and-scroll.md +++ b/docs/Compose/list-and-scroll.md @@ -39,6 +39,24 @@ fun NoBounceList(data: List) { } ``` +### iOS 交互式键盘收起:`Modifier.keyboardDismissModeInteractiveIOS` + +在 iOS 上将底层 `UIScrollView.keyboardDismissMode` 设为 `interactive`,使用户拖动列表时键盘跟随手势逐步退出。其他平台忽略此渲染提示并保持原有行为。 + +```kotlin +LazyColumn( + modifier = Modifier + .fillMaxSize() + .keyboardDismissModeInteractiveIOS(), +) { + items(messages) { message -> + Text(message) + } +} +``` + +传入 `false` 会把 iOS scroller 恢复为 `UIScrollViewKeyboardDismissModeNone`。 + ### 嵌套滚动策略:`Modifier.nestedScroll` - 扩展函数:`Modifier.nestedScroll(scrollUp: NestedScrollMode, scrollDown: NestedScrollMode)` @@ -173,7 +191,8 @@ fun PullToRefreshList(data: List) { | 参数 | 默认值 | 说明 | |------|--------|------| | `topInset` | `0.dp` | overlay HeaderBar 等场景下,PTR item 顶部的额外留白。传 **header 展开时的最大高度**,不是动画中的实时高度。框架会在 PTR item 内部自动应用等效 `padding(top)`,**请勿**再在 `modifier` 上重复设置 `padding(top = ...)`。未设置时行为与原来一致。 | -| `refreshThreshold` | `80.dp` | 触发刷新的下拉距离 | +| `refreshThreshold` | `80.dp` | 触发刷新的下拉距离;同一个 `scrollState` 重组时更新即可生效,无需重建列表状态。 | +| `holdRefreshInset` | `true` | 刷新期间是否持续保留 `refreshThreshold` 高度的顶部 content inset。若刷新进度由列表外的固定 Header 展示,并要求松手后列表内容立即回到原位,可设为 `false`;拖动阶段的 progress 与阈值触发语义不变。同一个 `scrollState` 下运行时切换也会作用于当前/下一次 pull。 | #### overlay HeaderBar(`topInset`) @@ -386,5 +405,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 复用、插入/删除时出现错乱。 - - 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 尾部的“挂全局”分支(浏览器