diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index cfb7b3815..9dd524f84 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -3,7 +3,7 @@ name: Android CI on: push: branches-ignore: - - 'renovate-*' + - 'renovate/*' paths-ignore: - '**.md' - '**.txt' @@ -40,7 +40,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 - name: Grant Permissions for Gradle run: chmod +x gradlew @@ -60,7 +60,7 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MVN_SIGNING_KEY_PASSWORD }} - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: ${{ !github.head_ref }} with: name: apk-debug diff --git a/README.es.md b/README.es.md index 55ffd804f..6c594f4e9 100644 --- a/README.es.md +++ b/README.es.md @@ -2,8 +2,8 @@ ![Banner](/images/editor_banner.jpg) ---- -[![CI](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml) -[![GitHub license](https://img.shields.io/github/license/Rosemoe/CodeEditor)](https://github.com/Rosemoe/CodeEditor/blob/main/LICENSE) +[![CI](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml) +![GitHub License](https://img.shields.io/github/license/Rosemoe/sora-editor?link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE&link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE) [![Maven Central](https://img.shields.io/maven-central/v/io.github.rosemoe/editor.svg?label=Maven%20Central)]((https://search.maven.org/search?q=io.github.rosemoe%20editor)) [![Telegram](https://img.shields.io/badge/Join-Telegram-blue)](https://t.me/rosemoe_code_editor) [![QQ](https://img.shields.io/badge/Join-QQ_Group-ff69b4)](https://jq.qq.com/?_wv=1027&k=n68uxQws) diff --git a/README.jp.md b/README.jp.md index e0dc09e49..d9e0f2b0d 100644 --- a/README.jp.md +++ b/README.jp.md @@ -2,8 +2,8 @@ ![Banner](/images/editor_banner.jpg) ---- -[![CI](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml) -[![GitHub license](https://img.shields.io/github/license/Rosemoe/CodeEditor)](https://github.com/Rosemoe/CodeEditor/blob/main/LICENSE) +[![CI](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml) +![GitHub License](https://img.shields.io/github/license/Rosemoe/sora-editor?link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE&link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE) [![Maven Central](https://img.shields.io/maven-central/v/io.github.rosemoe/editor.svg?label=Maven%20Central)]((https://search.maven.org/search?q=io.github.rosemoe%20editor)) [![Telegram](https://img.shields.io/badge/Join-Telegram-blue)](https://t.me/rosemoe_code_editor) [![QQ](https://img.shields.io/badge/Join-QQ_Group-ff69b4)](https://jq.qq.com/?_wv=1027&k=n68uxQws) diff --git a/README.zh-cn.md b/README.zh-cn.md index b315570a3..0435f1bcc 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -2,8 +2,8 @@ ![Banner](/images/editor_banner.jpg) ---- -[![CI](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/CodeEditor/actions/workflows/gradle.yml) -[![GitHub license](https://img.shields.io/github/license/Rosemoe/CodeEditor)](https://github.com/Rosemoe/CodeEditor/blob/main/LICENSE) +[![CI](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml/badge.svg?event=push)](https://github.com/Rosemoe/sora-editor/actions/workflows/gradle.yml) +![GitHub License](https://img.shields.io/github/license/Rosemoe/sora-editor?link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE&link=https%3A%2F%2Fgithub.com%2FRosemoe%2Fsora-editor%2Fblob%2Fmain%2FLICENSE) [![Maven Central](https://img.shields.io/maven-central/v/io.github.rosemoe/editor.svg?label=Maven%20Central)]((https://search.maven.org/search?q=io.github.rosemoe%20editor)) [![Telegram](https://img.shields.io/badge/Join-Telegram-blue)](https://t.me/rosemoe_code_editor) [![QQ](https://img.shields.io/badge/Join-QQ_Group-ff69b4)](https://jq.qq.com/?_wv=1027&k=n68uxQws) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f446dddf..c0927d67d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { debugImplementation(libs.leakcanary) testImplementation(libs.junit) + testImplementation(libs.tests.google.truth) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.test.espresso) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 48338e8a1..051d782b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,11 @@ android:shell="true" tools:targetApi="q" /> + startActivity() + R.id.open_paged_edit -> startActivity() R.id.open_lsp_activity -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { MaterialAlertDialogBuilder(this) diff --git a/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditActivity.kt b/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditActivity.kt new file mode 100644 index 000000000..58782c176 --- /dev/null +++ b/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditActivity.kt @@ -0,0 +1,232 @@ +/******************************************************************************* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + ******************************************************************************/ + +package io.github.rosemoe.sora.app.tests.paged + +import android.graphics.Typeface +import android.os.Bundle +import android.util.TypedValue +import android.view.Menu +import android.view.MenuItem +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.activity.addCallback +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.github.rosemoe.sora.app.BaseEditorActivity +import io.github.rosemoe.sora.langs.java.JavaLanguage +import io.github.rosemoe.sora.utils.toast +import io.github.rosemoe.sora.widget.schemes.SchemeEclipse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PagedEditActivity : BaseEditorActivity() { + + companion object { + const val MyPageSize = 512 * 1024 + } + + private var pagedEditSession: PagedEditSession? = null + + private var pageIndex = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val typeface = Typeface.createFromAsset(assets, "JetBrainsMono-Regular.ttf") + editor.typefaceText = typeface + editor.typefaceLineNumber = typeface + editor.setEditorLanguage(JavaLanguage()) + editor.colorScheme = SchemeEclipse() + + val sourceFile = filesDir.resolve("big_sample.txt") + runTaskWithModalDialog { + runCatching { + unzipSampleFile(forced = false) + val tmpDir = filesDir.resolve("session") + sourceFile.reader().use { + pagedEditSession = + PagedEditSession(it, tmpDir, MyPageSize) + } + pagedEditSession?.loadPageToEditor(0, editor) + pageIndex = 0 + updateUiPageIndex() + }.onFailure { + it.printStackTrace() + pagedEditSession?.close() + pagedEditSession = null + pageIndex = -1 + withContext(Dispatchers.Main) { + toast("Failed to setup paged edit session") + } + } + } + + onBackPressedDispatcher.addCallback { + handleSaveOnBack() + } + } + + fun runTaskWithModalDialog(title: CharSequence = "Loading", task: suspend () -> Unit) { + val pd = MaterialAlertDialogBuilder(this) + .setTitle(title) + .setView(RelativeLayout(this).also { layout -> + ProgressBar(this).also { pb -> + layout.addView( + pb, + RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ).also { + it.addRule(RelativeLayout.CENTER_IN_PARENT) + it.topMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 16f, + resources.displayMetrics + ).toInt() + it.bottomMargin = it.topMargin + }) + } + }) + .setCancelable(false) + .show() + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + task() + } + withContext(Dispatchers.Main) { + pd.dismiss() + } + } + } + + private fun unzipSampleFile(forced: Boolean = false) { + filesDir.mkdirs() + val sourceFile = filesDir.resolve("big_sample.txt") + if (sourceFile.exists() && !forced) { + return + } + assets.open("samples/big_sample.txt").use { input -> + sourceFile.outputStream().use { + input.copyTo(it) + } + } + } + + private fun updateUiPageIndex() { + runOnUiThread { + Toast.makeText( + this, + "Page ${pageIndex + 1} of ${pagedEditSession?.pageCount}", + Toast.LENGTH_SHORT + ).show() + setTitle("Page ${pageIndex + 1} of ${pagedEditSession?.pageCount}") + } + } + + private fun handleSaveOnBack() { + if (pagedEditSession == null || pageIndex == -1) { + finish() + return + } + runTaskWithModalDialog(title = "Saving") { + pagedEditSession?.unloadPageFromEditor(pageIndex, editor) + val sourceFile = filesDir.resolve("big_sample.txt") + pagedEditSession?.writeTo(sourceFile) + pageIndex = -1 + withContext(Dispatchers.Main) { + toast("Changes saved") + finish() + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + handleSaveOnBack() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menu?.add("Previous Page")?.setOnMenuItemClickListener { + if (pagedEditSession == null || pageIndex == -1) { + return@setOnMenuItemClickListener true + } + if (pageIndex == 0) { + updateUiPageIndex() + } else { + runTaskWithModalDialog { + pagedEditSession?.apply { + unloadPageFromEditor(pageIndex, editor) + loadPageToEditor(--pageIndex, editor) + } + updateUiPageIndex() + } + } + return@setOnMenuItemClickListener true + } + menu?.add("Next Page")?.setOnMenuItemClickListener { + if (pagedEditSession == null || pageIndex == -1) { + return@setOnMenuItemClickListener true + } + if (pageIndex == pagedEditSession!!.pageCount - 1) { + updateUiPageIndex() + } else { + runTaskWithModalDialog { + pagedEditSession?.apply { + unloadPageFromEditor(pageIndex, editor) + loadPageToEditor(++pageIndex, editor) + } + updateUiPageIndex() + } + } + return@setOnMenuItemClickListener true + } + menu?.add("Reset File")?.setOnMenuItemClickListener { + runTaskWithModalDialog { + pagedEditSession?.close() + pagedEditSession = null + pageIndex = -1 + unzipSampleFile(forced = true) + withContext(Dispatchers.Main) { + toast("File is reset, Please reopen activity") + finish() + } + } + return@setOnMenuItemClickListener true + } + return super.onCreateOptionsMenu(menu) + } + + override fun onDestroy() { + super.onDestroy() + pagedEditSession?.close() + editor.release() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSession.kt b/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSession.kt new file mode 100644 index 000000000..43c5a1db3 --- /dev/null +++ b/app/src/main/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSession.kt @@ -0,0 +1,250 @@ +/******************************************************************************* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + ******************************************************************************/ + +package io.github.rosemoe.sora.app.tests.paged + +import io.github.rosemoe.sora.text.ContentIO +import io.github.rosemoe.sora.widget.CodeEditor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.Reader +import java.io.Writer +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.text.BreakIterator +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.jvm.Throws + +class PagedEditSession @Throws(IOException::class) constructor( + source: Reader, + val tmpDir: File, + pageSize: Int = DefaultPageSize +) : Closeable { + + companion object { + const val DefaultPageSize = 10000000 + const val MinPageSize = 16 + val InternalStorageCharset = Charsets.UTF_16BE + private const val PagePrefix = "page-" + private const val SwapTmpPrefix = "tmp-" + private const val NumberPadLen = 5 + + @Throws(IOException::class) + fun restoreSessionFile(tmpDir: File, outputFile: File, charset: Charset = Charsets.UTF_8) { + outputFile.writer(charset).use { output -> + tmpDir.listFiles()?.filter { + it.name.startsWith(PagePrefix) + }?.sortedBy { + outputFile.name.removePrefix(PagePrefix).toInt() + }?.forEach { file -> + file.reader(InternalStorageCharset).use { + it.copyTo(output) + } + } + } + } + } + + private val operationLock = ReentrantLock() + + internal val pages = mutableListOf() + + private var tmpId = 0 + + val pageCount: Int + get() = pages.size + + init { + if (pageSize < MinPageSize) { + throw IllegalArgumentException("Page size must be at least $MinPageSize") + } + tmpDir.mkdirs() + + val buffer = CharBuffer.wrap(CharArray(8192)) + var count = source.read(buffer.array(), buffer.arrayOffset(), buffer.limit()) + if (count >= 0) { + buffer.limit(count) + } + + var currPageIndex = 0 + var currWritten = 0 + var currOutput = getPageFileForIndex(currPageIndex).writer(InternalStorageCharset) + val directWriteSize = pageSize - MinPageSize + val itr = BreakIterator.getCharacterInstance() + + while (true) { + val charsToWrite = (directWriteSize - currWritten).coerceIn(0, buffer.remaining()) + var needInput = false + if (charsToWrite == 0 && (buffer.hasRemaining() || !buffer.hasRemaining() && count == -1)) { + // Direct write range is full + if (buffer.remaining() < 2 * MinPageSize && count != -1) { + // Need more input for character range detection + needInput = true + } else { + var pageLength = currWritten + if (buffer.hasRemaining()) { + val limit = buffer.remaining().coerceAtMost(MinPageSize * 2) + val text = buffer.substring(0, limit) + itr.setText(text) + val nextBoundary = + itr.following((MinPageSize - 1).coerceAtMost(text.length)) + val sliceLength = if (nextBoundary == BreakIterator.DONE) { + text.length + } else { + nextBoundary + } + currOutput.write(text.substring(0, sliceLength)) + pageLength += sliceLength + buffer.position(buffer.position() + sliceLength) + } + + currOutput.flush() + currOutput.close() + pages.add(Page(pageLength.toLong())) + if (!buffer.hasRemaining() && count == -1) { + break + } + currOutput = getPageFileForIndex(++currPageIndex).writer(InternalStorageCharset) + currWritten = 0 + } + } else if (charsToWrite > 0) { + currOutput.write( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + charsToWrite + ) + buffer.position(buffer.position() + charsToWrite) + currWritten += charsToWrite + } + if ((needInput || !buffer.hasRemaining()) && count != -1) { + buffer.compact() + val remaining = buffer.limit() - buffer.position() + if (remaining > 0) { + count = source.read( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + remaining + ) + buffer.limit(buffer.position() + count.coerceAtLeast(0)) + buffer.position(0) + } + } + } + } + + internal fun getPageFileForIndex(index: Int): File { + val pageFile = tmpDir.resolve("$PagePrefix${index.toString().padStart(NumberPadLen, '0')}") + return pageFile + } + + private fun newTmpFile(): File { + val pageFile = + tmpDir.resolve("$SwapTmpPrefix-${(tmpId++).toString().padStart(NumberPadLen, '0')}") + return pageFile + } + + @Throws(IOException::class) + suspend fun loadPageToEditor(pageIndex: Int, editor: CodeEditor) { + val page = pages[pageIndex] + val content = withContext(Dispatchers.IO) { + operationLock.withLock { + InputStreamReader( + FileInputStream(getPageFileForIndex(pageIndex)), + InternalStorageCharset + ).use { + ContentIO.createFrom(it) + } + } + } + withContext(Dispatchers.Main) { + editor.setText(content) + } + } + + @Throws(IOException::class) + suspend fun unloadPageFromEditor(pageIndex: Int, editor: CodeEditor) { + val page = pages[pageIndex] + val text = editor.text.copyTextShallow() + withContext(Dispatchers.IO) { + operationLock.withLock { + val tmp = newTmpFile() + FileOutputStream(tmp).use { + ContentIO.writeTo(text, it, InternalStorageCharset, true) + } + val pageFile = getPageFileForIndex(pageIndex) + page.charsLength = text.length.toLong() + pageFile.delete() + tmp.renameTo(pageFile) + } + text.release() + } + } + + @Throws(IOException::class) + suspend fun writeTo(file: File, charset: Charset = Charsets.UTF_8) { + withContext(Dispatchers.IO) { + file.writer(charset).use { + writeTo(it) + } + } + } + + @Throws(IOException::class) + suspend fun writeTo(output: Writer) { + withContext(Dispatchers.IO) { + operationLock.withLock { + pages.indices.forEach { index -> + val pageFile = getPageFileForIndex(index) + pageFile.reader(InternalStorageCharset).use { + it.copyTo(output) + } + } + } + } + } + +// fun isModified() = pages.any { it.isModified } + + override fun close() { + tmpDir.deleteRecursively() + } + + internal data class Page( + var charsLength: Long, +// var isModified: Boolean = false + ) + +} \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index badaf9361..a470e58fb 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -129,6 +129,9 @@ + diff --git a/app/src/test/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSessionTest.kt b/app/src/test/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSessionTest.kt new file mode 100644 index 000000000..61721a1bf --- /dev/null +++ b/app/src/test/java/io/github/rosemoe/sora/app/tests/paged/PagedEditSessionTest.kt @@ -0,0 +1,97 @@ +/******************************************************************************* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + ******************************************************************************/ + +package io.github.rosemoe.sora.app.tests.paged + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.io.File +import java.io.StringReader + +class PagedEditSessionTest { + + private fun useTempDir(block: (tmpDir: File) -> Unit) { + val tmpRoot = File(System.getProperty("java.io.tmpdir", ".")!!) + val tmpDir = tmpRoot.resolve("sora-editor-test-${System.currentTimeMillis()}") + tmpDir.mkdirs() + try { + block(tmpDir) + } finally { + tmpDir.deleteRecursively() + } + } + + @Test + fun `test simple pages`() { + val pageSize = 512 * 1024 + val chars = "abcdefghijklmnopqrstuvwxyz" + val text = chars.repeat(pageSize) + useTempDir { tmpDir -> + PagedEditSession( + StringReader(text), + tmpDir, + pageSize + ).use { + assertThat(it.pageCount).isEqualTo(text.length / pageSize) + } + } + } + + @Test + fun `test simple surrogates`() { + val emoji = "\uD83E\uDD14" // 🤔 + val text = emoji.repeat(16) + useTempDir { tmpDir -> + PagedEditSession( + StringReader(text), + tmpDir, + 17 + ).use { + assertThat(it.pageCount).isEqualTo(2) + assertThat(it.pages[0].charsLength).isEqualTo(18) + assertThat(it.pages[1].charsLength).isEqualTo(text.length - 18) + } + } + } + + @Test + fun `test complex emoji pages`() { + // Emoji 👨‍👩‍👧‍👦, represented by 11 chars + val text = + "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66".repeat(2) + useTempDir { tmpDir -> + PagedEditSession( + StringReader(text), + tmpDir, + 16 + ).use { + assertThat(it.pageCount).isEqualTo(1) + assertThat( + it.getPageFileForIndex(0).readText(PagedEditSession.InternalStorageCharset) + ).isEqualTo(text) + } + } + } + +} diff --git a/editor-lsp/build.gradle.kts b/editor-lsp/build.gradle.kts index ced9fec39..078f5de69 100644 --- a/editor-lsp/build.gradle.kts +++ b/editor-lsp/build.gradle.kts @@ -58,4 +58,9 @@ dependencies { compileOnly(project(":editor")) implementation(libs.lsp4j) implementation(libs.kotlinx.coroutines) + testImplementation(libs.junit) + testImplementation(libs.tests.google.truth) + testImplementation(libs.tests.robolectric) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.espresso) } diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/text/SimpleMarkdownRenderer.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/text/SimpleMarkdownRenderer.kt index 6c2502c55..6f71e3963 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/text/SimpleMarkdownRenderer.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/text/SimpleMarkdownRenderer.kt @@ -16,6 +16,7 @@ import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.text.style.URLSpan import android.util.Base64 +import androidx.annotation.VisibleForTesting import java.util.Locale object SimpleMarkdownRenderer { @@ -432,7 +433,8 @@ object SimpleMarkdownRenderer { } } - private fun parseBlocks(text: String): List { + @VisibleForTesting + internal fun parseBlocks(text: String): List { val blocks = mutableListOf() val lines = text.split('\n') var index = 0 @@ -481,12 +483,15 @@ object SimpleMarkdownRenderer { private fun parseCodeBlock(lines: List, startIndex: Int): Pair { val firstLine = lines[startIndex].trim() - val language = if (firstLine.length > 3) firstLine.substring(3).trim() else null + val backquotes = firstLine.takeWhile { it == '`' } + val language = + if (firstLine.length > backquotes.length) firstLine.substring(backquotes.length) + .trim() else null val builder = StringBuilder() var index = startIndex + 1 while (index < lines.size) { val line = lines[index] - if (line.trim().startsWith("```")) { + if (line.trim() == backquotes) { index++ break } @@ -929,7 +934,8 @@ object SimpleMarkdownRenderer { fun load(src: String): Drawable? } - private sealed interface Block { + @VisibleForTesting + internal interface Block { class Heading(val level: Int, val inlines: List) : Block class Paragraph(val inlines: List) : Block class CodeBlock(val content: String, val language: String?) : Block @@ -940,7 +946,8 @@ object SimpleMarkdownRenderer { data object HorizontalRule : Block } - private sealed interface Inline { + @VisibleForTesting + internal sealed interface Inline { class Text(val value: String) : Inline class Bold(val children: List) : Inline class Italic(val children: List) : Inline diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/color/DocumentColor.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/color/DocumentColor.kt index 6de4b7558..a9bf90cf6 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/color/DocumentColor.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/color/DocumentColor.kt @@ -66,6 +66,7 @@ class DocumentColorEvent : AsyncEventListener() { private fun getOrCreateFlow( coroutineScope: CoroutineScope, + context: EventContext, uri: FileUri ): MutableSharedFlow { return requestFlows.getOrPut(uri) { @@ -79,7 +80,7 @@ class DocumentColorEvent : AsyncEventListener() { flow .debounce(50) .collect { request -> - processInlayHintRequest(request) + processDocumentColorRequest(request, context) } } @@ -91,11 +92,14 @@ class DocumentColorEvent : AsyncEventListener() { val editor = context.get("lsp-editor") val uri = editor.uri - val flow = getOrCreateFlow(editor.coroutineScope, uri) + val flow = getOrCreateFlow(editor.coroutineScope, context, uri) flow.tryEmit(DocumentColorRequest(editor, uri)) } - private suspend fun processInlayHintRequest(request: DocumentColorRequest) = + private suspend fun processDocumentColorRequest( + request: DocumentColorRequest, + context: EventContext + ) = withContext(Dispatchers.IO) { val editor = request.editor @@ -109,9 +113,14 @@ class DocumentColorEvent : AsyncEventListener() { val documentColors: List? - withTimeout(Timeout[Timeouts.DOC_HIGHLIGHT].toLong()) { - documentColors = - future.await() + try { + withTimeout(Timeout[Timeouts.DOC_HIGHLIGHT].toLong()) { + documentColors = + future.await() + } + } catch (e: Exception) { + onException(context, e) + return@withContext } if (documentColors.isNullOrEmpty()) { diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/inlayhint/InlayHintEvent.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/inlayhint/InlayHintEvent.kt index 3d5cf2ed6..33e207ac4 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/inlayhint/InlayHintEvent.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/inlayhint/InlayHintEvent.kt @@ -70,6 +70,7 @@ class InlayHintEvent : AsyncEventListener() { private fun getOrCreateFlow( coroutineScope: CoroutineScope, + context: EventContext, uri: String ): MutableSharedFlow { return requestFlows.getOrPut(uri) { @@ -83,7 +84,7 @@ class InlayHintEvent : AsyncEventListener() { flow .debounce(50) .collect { request -> - processInlayHintRequest(request) + processInlayHintRequest(request, context) } } @@ -97,11 +98,11 @@ class InlayHintEvent : AsyncEventListener() { val uri = editor.uri.toString() - val flow = getOrCreateFlow(editor.coroutineScope, uri) + val flow = getOrCreateFlow(editor.coroutineScope, context, uri) flow.tryEmit(InlayHintRequest(editor, position)) } - private suspend fun processInlayHintRequest(request: InlayHintRequest) = + private suspend fun processInlayHintRequest(request: InlayHintRequest, context: EventContext) = withContext(Dispatchers.IO) { val editor = request.editor val position = request.position @@ -133,8 +134,13 @@ class InlayHintEvent : AsyncEventListener() { val inlayHints: List? - withTimeout(Timeout[Timeouts.INLAY_HINT].toLong()) { - inlayHints = future.await() + try { + withTimeout(Timeout[Timeouts.INLAY_HINT].toLong()) { + inlayHints = future.await() + } + } catch (e: Exception) { + onException(context, e) + return@withContext } if (inlayHints.isNullOrEmpty()) { diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/workspace/WorkSpaceApplyEditEvent.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/workspace/WorkSpaceApplyEditEvent.kt index 52d933406..a9188e361 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/workspace/WorkSpaceApplyEditEvent.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/events/workspace/WorkSpaceApplyEditEvent.kt @@ -24,6 +24,7 @@ package io.github.rosemoe.sora.lsp.events.workspace +import io.github.rosemoe.sora.lang.completion.snippet.parser.CodeSnippetParser import io.github.rosemoe.sora.lsp.editor.LspEditor import io.github.rosemoe.sora.lsp.editor.LspProject import io.github.rosemoe.sora.lsp.events.EventContext @@ -37,6 +38,8 @@ import io.github.rosemoe.sora.lsp.utils.toFileUri import io.github.rosemoe.sora.lsp.utils.toURI import org.eclipse.lsp4j.ApplyWorkspaceEditParams import org.eclipse.lsp4j.ResourceOperation +import org.eclipse.lsp4j.SnippetTextEdit +import org.eclipse.lsp4j.StringValueKind import org.eclipse.lsp4j.TextDocumentEdit import org.eclipse.lsp4j.TextEdit import org.eclipse.lsp4j.jsonrpc.messages.Either @@ -86,8 +89,20 @@ class WorkSpaceApplyEditEvent : EventListener { val editor = project.getEditor(uri) ?: throw LSPException("The url ${textDocument.uri} is not opened.") - applySingleChange(editor, uri, textDocumentEdit.edits) - + // TODO Start interactive snippet edit in future + applySingleChange(editor, uri, textDocumentEdit.edits.map { + if (it.isLeft) { + it.left + } else { + TextEdit( + it.right.range, if (it.right.snippet.kind == StringValueKind.SNIPPET) { + CodeSnippetParser.parse(it.right.snippet.value).toInsertTextForLsp() + } else { + it.right.snippet.value + } + ) + } + }) } private fun applyChanges(context: EventContext, changes: Map>) { diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/LspUtils.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/LspUtils.kt index 2e02b6961..e37c91c38 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/LspUtils.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/LspUtils.kt @@ -156,13 +156,19 @@ internal fun List.transformToEditorDiagnostics(editor: CodeEditor): val result = ArrayList() var id = 0L for (diagnosticSource in this) { + val message = if (diagnosticSource.message.isLeft) { + diagnosticSource.message.left + } else { + // TODO Support markdown in editor core + diagnosticSource.message.right.value + } val diagnostic = DiagnosticRegion( diagnosticSource.range.start.getIndex(editor), diagnosticSource.range.end.getIndex(editor), diagnosticSource.severity.toEditorLevel(), id++, DiagnosticDetail( - diagnosticSource.severity.name, diagnosticSource.message, null, diagnosticSource + diagnosticSource.severity.name, message, null, diagnosticSource ) ) result.add(diagnostic) diff --git a/editor-lsp/src/test/java/io/github/rosemoe/lsp/editor/text/SimpleMarkdownRendererTest.kt b/editor-lsp/src/test/java/io/github/rosemoe/lsp/editor/text/SimpleMarkdownRendererTest.kt new file mode 100644 index 000000000..62eb1baf2 --- /dev/null +++ b/editor-lsp/src/test/java/io/github/rosemoe/lsp/editor/text/SimpleMarkdownRendererTest.kt @@ -0,0 +1,76 @@ +/******************************************************************************* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + ******************************************************************************/ + +package io.github.rosemoe.lsp.editor.text + +import io.github.rosemoe.sora.lsp.editor.text.SimpleMarkdownRenderer +import org.junit.Test +import com.google.common.truth.Truth.assertThat + +class SimpleMarkdownRendererTest { + + @Test + fun `basic multiline code block`() { + val result = SimpleMarkdownRenderer.parseBlocks( + """ + ```java + public class Main { + public static void main(String[] args) {} + } + ``` + """.trimIndent() + ) + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(SimpleMarkdownRenderer.Block.CodeBlock::class.java) + + val codeBlock = result[0] as SimpleMarkdownRenderer.Block.CodeBlock + assertThat(codeBlock.language).isEqualTo("java") + val expectedContent = """ + public class Main { + public static void main(String[] args) {} + } + """.trimIndent() + assertThat(codeBlock.content).isEqualTo(expectedContent) + } + + @Test + fun `multiline code block with more than three backquotes`() { + // Tracking issue: #814 + val result = SimpleMarkdownRenderer.parseBlocks( + """ + ````java + ``` + ````Test```` + ```` + """.trimIndent() + ) + assertThat(result).hasSize(1) + assertThat(result[0]).isInstanceOf(SimpleMarkdownRenderer.Block.CodeBlock::class.java) + + val codeBlock = result[0] as SimpleMarkdownRenderer.Block.CodeBlock + assertThat(codeBlock.language).isEqualTo("java") + assertThat(codeBlock.content).isEqualTo("```\n````Test````") + } + +} \ No newline at end of file diff --git a/editor/src/main/java/io/github/rosemoe/sora/graphics/GraphicsCompat.java b/editor/src/main/java/io/github/rosemoe/sora/graphics/GraphicsCompat.java index 39cd89c84..af0c8045c 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/graphics/GraphicsCompat.java +++ b/editor/src/main/java/io/github/rosemoe/sora/graphics/GraphicsCompat.java @@ -26,9 +26,12 @@ import android.annotation.SuppressLint; import android.graphics.Canvas; import android.os.Build; +import android.text.GetChars; import androidx.annotation.NonNull; +import io.github.rosemoe.sora.util.TemporaryCharBuffer; + public class GraphicsCompat { /** @@ -37,12 +40,15 @@ public class GraphicsCompat { * As there is no hidden list checks in those API platforms, it's safe here to call the "New API". */ @SuppressLint("NewApi") - public static void drawTextRun(Canvas canvas, @NonNull char[] text, int index, int count, int contextIndex, + public static void drawTextRun(Canvas canvas, @NonNull GetChars text, int index, int count, int contextIndex, int contextCount, float x, float y, boolean isRtl, @NonNull android.graphics.Paint paint) { - canvas.drawTextRun(text, index, count, contextIndex, contextCount, x, y, isRtl, paint); + var buffer = TemporaryCharBuffer.obtain(contextCount); + text.getChars(contextIndex, contextIndex + contextCount, buffer, 0); + canvas.drawTextRun(buffer, index - contextIndex, count, 0, contextCount, x, y, isRtl, paint); + TemporaryCharBuffer.recycle(buffer); } - public static float getRunAdvance(Paint paint, char[] text, int start, int end, int contextStart, int contextEnd, + public static float getRunAdvance(Paint paint, GetChars text, int start, int end, int contextStart, int contextEnd, boolean isRtl, int offset) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return paint.getRunAdvance(text, start, end, contextStart, contextEnd, isRtl, offset); diff --git a/editor/src/main/java/io/github/rosemoe/sora/graphics/Paint.java b/editor/src/main/java/io/github/rosemoe/sora/graphics/Paint.java index d81c770e2..a7e6d45d4 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/graphics/Paint.java +++ b/editor/src/main/java/io/github/rosemoe/sora/graphics/Paint.java @@ -26,12 +26,13 @@ import android.annotation.SuppressLint; import android.graphics.Typeface; import android.os.Build; +import android.text.GetChars; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.github.rosemoe.sora.text.ContentLine; import io.github.rosemoe.sora.text.FunctionCharacters; +import io.github.rosemoe.sora.util.TemporaryCharBuffer; public class Paint extends android.graphics.Paint { @@ -86,11 +87,13 @@ public void setLetterSpacing(float letterSpacing) { } @SuppressLint("NewApi") - public float myGetTextRunAdvances(@NonNull char[] chars, int index, int count, int contextIndex, int contextCount, boolean isRtl, @Nullable float[] advances, int advancesIndex) { - float advance = getTextRunAdvances(chars, index, count, contextIndex, contextCount, isRtl, advances, advancesIndex); + public float myGetTextRunAdvances(@NonNull GetChars chars, int index, int count, int contextIndex, int contextCount, boolean isRtl, @Nullable float[] advances, int advancesIndex) { + var buffer = TemporaryCharBuffer.obtain(contextCount); + chars.getChars(contextIndex, contextIndex + contextCount, buffer, 0); + float advance = getTextRunAdvances(buffer, index - contextIndex, count, 0, contextCount, isRtl, advances, advancesIndex); if (renderFunctionCharacters) { for (int i = 0; i < count; i++) { - char ch = chars[index + i]; + char ch = chars.charAt(index + i); if (FunctionCharacters.isEditorFunctionChar(ch)) { float width = measureText(FunctionCharacters.getNameForFunctionCharacter(ch)); if (advances != null) { @@ -103,34 +106,34 @@ public float myGetTextRunAdvances(@NonNull char[] chars, int index, int count, i } } } + TemporaryCharBuffer.recycle(buffer); return advance; } /** * Get the advance of text with the context positions related to shaping the characters */ - public float measureTextRunAdvance(char[] text, int start, int end, int contextStart, int contextEnd, boolean isRtl) { + public float measureTextRunAdvance(GetChars text, int start, int end, int contextStart, int contextEnd, boolean isRtl) { return myGetTextRunAdvances(text, start, end - start, contextStart, contextEnd - contextStart, isRtl, null, 0); } /** - * Find offset for a certain advance returned by {@link #measureTextRunAdvance(char[], int, int, int, int, boolean)} + * Find offset for a certain advance returned by {@link #measureTextRunAdvance(GetChars, int, int, int, int, boolean)} */ - public int findOffsetByRunAdvance(ContentLine text, int start, int end, + public int findOffsetByRunAdvance(GetChars text, int start, int end, int contextStart, int contextEnd, boolean isRtl, float advance) { if (renderFunctionCharacters) { int lastEnd = start; float current = 0f; - var textChars = text.getBackingCharArray(); for (int i = start;i < end;i++) { - char ch = textChars[i]; + char ch = text.charAt(i); if (FunctionCharacters.isEditorFunctionChar(ch)) { int result = lastEnd == i ? i : breakTextImpl(text, lastEnd, i, contextStart, contextEnd, isRtl, advance - current); if (result < i) { return result; } - current += measureTextRunAdvance(textChars, lastEnd, i, contextStart, contextEnd, isRtl); + current += measureTextRunAdvance(text, lastEnd, i, contextStart, contextEnd, isRtl); current += measureText(FunctionCharacters.getNameForFunctionCharacter(ch)); if (current >= advance) { return i; @@ -147,11 +150,17 @@ public int findOffsetByRunAdvance(ContentLine text, int start, int end, } } - private int breakTextImpl(ContentLine text, int start, int end, int contextStart, int contextEnd, boolean isRtl, float advance) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return getOffsetForAdvance(text.getBackingCharArray(), start, end, contextStart, contextEnd, isRtl, advance); - } else { - return start + breakText(text.getBackingCharArray(), start, end - start, advance, null); + private int breakTextImpl(GetChars text, int start, int end, int contextStart, int contextEnd, boolean isRtl, float advance) { + var buffer = TemporaryCharBuffer.obtain(contextEnd - contextStart); + text.getChars(contextStart, contextEnd, buffer, 0); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return contextStart + getOffsetForAdvance(buffer, start - contextStart, end - contextStart, 0, contextEnd - contextStart, isRtl, advance); + } else { + return start + breakText(buffer, start - contextStart, end - start, advance, null); + } + } finally { + TemporaryCharBuffer.recycle(buffer); } } diff --git a/editor/src/main/java/io/github/rosemoe/sora/graphics/TextRow.java b/editor/src/main/java/io/github/rosemoe/sora/graphics/TextRow.java index 839f72b76..f4d41220c 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/graphics/TextRow.java +++ b/editor/src/main/java/io/github/rosemoe/sora/graphics/TextRow.java @@ -26,6 +26,7 @@ import android.graphics.Canvas; import android.graphics.Path; import android.graphics.RectF; +import android.text.GetChars; import android.util.Log; import androidx.annotation.NonNull; @@ -156,15 +157,14 @@ public void setSelectedRange(int start, int end) { */ private float getSingleRunAdvancesForBreaking(int start, int end, int contextStart, int contextEnd, boolean isRtl, float[] advances) { - var chars = text.getBackingCharArray(); int lastEnd = start; float tabWidth = params.getTabWidth() * paint.getSpaceWidth(); float width = 0f; for (int i = start; i <= end; i++) { - if (i == end || chars[i] == '\t') { + if (i == end || text.charAt(i) == '\t') { // commit [lastEnd, i) if (i > lastEnd) - width += paint.myGetTextRunAdvances(chars, lastEnd, i - lastEnd, contextStart, contextEnd - contextStart, isRtl, advances, (lastEnd - start)); + width += paint.myGetTextRunAdvances(text, lastEnd, i - lastEnd, contextStart, contextEnd - contextStart, isRtl, advances, (lastEnd - start)); if (i < end) { width += tabWidth; if (advances != null) @@ -188,7 +188,7 @@ private float getTextRunAdvancesCacheable(int index, int count, int contextIndex } return measureCache.getAdvancesSum(index, index + count); } - return paint.myGetTextRunAdvances(text.getBackingCharArray(), index, count, contextIndex, contextCount, isRtl, advances, advancesIndex); + return paint.myGetTextRunAdvances(text, index, count, contextIndex, contextCount, isRtl, advances, advancesIndex); } /** @@ -199,7 +199,7 @@ private float getRunAdvanceCacheable(int offset, int start, int end, if (measureCache != null) { return measureCache.getAdvancesSum(start, offset); } - return GraphicsCompat.getRunAdvance(paint, text.getBackingCharArray(), start, end, contextStart, contextEnd, isRtl, offset); + return GraphicsCompat.getRunAdvance(paint, text, start, end, contextStart, contextEnd, isRtl, offset); } /** @@ -579,19 +579,18 @@ protected void drawFunctionCharacter(Canvas canvas, float offsetX, float width, private void commitTextRunToCanvas(int paintStart, int paintEnd, int contextStart, int contextEnd, boolean isRtl, Canvas canvas, float offset, float width) { if (paint.isRenderFunctionCharacters()) { - var chars = text.getBackingCharArray(); int lastEnd = paintStart; float initOffset = offset + (isRtl ? width : 0f); float drawOffset = initOffset; for (int i = paintStart; i <= paintEnd; i++) { char ch = '\0'; - if (i == paintEnd || FunctionCharacters.isEditorFunctionChar(ch = chars[i])) { + if (i == paintEnd || FunctionCharacters.isEditorFunctionChar(ch = text.charAt(i))) { // commit [lastEnd, i) if (i - lastEnd > 0) { if (isRtl) { paint.setTextAlign(android.graphics.Paint.Align.RIGHT); } - GraphicsCompat.drawTextRun(canvas, chars, lastEnd, i - lastEnd, contextStart, contextEnd - contextStart, drawOffset, params.getTextBaseline(), isRtl, paint); + GraphicsCompat.drawTextRun(canvas, text, lastEnd, i - lastEnd, contextStart, contextEnd - contextStart, drawOffset, params.getTextBaseline(), isRtl, paint); if (isRtl) { paint.setTextAlign(android.graphics.Paint.Align.LEFT); } @@ -608,13 +607,13 @@ private void commitTextRunToCanvas(int paintStart, int paintEnd, int contextStar } } } else { - GraphicsCompat.drawTextRun(canvas, text.getBackingCharArray(), paintStart, paintEnd - paintStart, contextStart, contextEnd - contextStart, offset, params.getTextBaseline(), isRtl, paint); + GraphicsCompat.drawTextRun(canvas, text, paintStart, paintEnd - paintStart, contextStart, contextEnd - contextStart, offset, params.getTextBaseline(), isRtl, paint); } } private void commitTextRunToConsumer(int paintStart, int paintEnd, int contextStart, int contextEnd, boolean isRtl, Canvas canvas, float offset, float width, IteratingContext ctx) { - ctx.drawTextConsumer.drawText(canvas, text.getBackingCharArray(), paintStart, paintEnd - paintStart, contextStart, contextEnd - contextStart, isRtl, offset, width, params, ctx.currentSpan); + ctx.drawTextConsumer.drawText(canvas, text, paintStart, paintEnd - paintStart, contextStart, contextEnd - contextStart, isRtl, offset, width, params, ctx.currentSpan); } /** @@ -638,12 +637,11 @@ private void commitTextRunAutoTruncated(int paintStart, int paintEnd, int contex if (commitStart < commitEnd) { int commitContextStart = commitStart, commitContextEnd = commitEnd; - var chars = text.getBackingCharArray(); - while (commitContextStart - 1 >= contextStart && chars[commitContextStart - 1] != ' ' + while (commitContextStart - 1 >= contextStart && text.charAt(commitContextStart - 1) != ' ' && (commitContextEnd - commitContextStart) < MAX_CONTEXT_LENGTH) { commitContextStart--; } - while (commitContextEnd + 1 < contextEnd && chars[commitContextEnd] != ' ' + while (commitContextEnd + 1 < contextEnd && text.charAt(commitContextEnd) != ' ' && (commitContextEnd - commitContextStart) < MAX_CONTEXT_LENGTH) { commitContextEnd++; } @@ -880,7 +878,7 @@ private float handleSingleStyledText(int paintStart, int paintEnd, boolean isRtl } /** - * Split text in an unidirectional run with span boundaries + * Split text in a unidirectional run with span boundaries */ private float handleMultiStyledText(int start, int end, boolean isRtl, ListPointers pointers, Canvas canvas, float offset, IteratingContext ctx) { @@ -955,7 +953,6 @@ private float handleMultiStyledText(int start, int end, boolean isRtl, ListPoint */ private float handleSingleTextElement(RowElement e, ListPointers pointers, Canvas canvas, float offset, IteratingContext ctx) { - var chars = text.getBackingCharArray(); boolean isRtl = e.isRtlText; float localOffset = 0f; int lastEnd = isRtl ? e.endColumn : e.startColumn; @@ -964,7 +961,7 @@ private float handleSingleTextElement(RowElement e, ListPointers pointers, for (int index = (isRtl ? e.endColumn - 1 : e.startColumn); isRtl ? (index >= terminalIndex) : (index <= terminalIndex); index += (isRtl ? -1 : 1)) { - if (index == terminalIndex || chars[index] == '\t') { + if (index == terminalIndex || text.charAt(index) == '\t') { int regionStart = isRtl ? index + 1 : lastEnd; int regionEnd = isRtl ? lastEnd : index; localOffset += handleMultiStyledText(regionStart, regionEnd, isRtl, pointers, canvas, offset + localOffset, ctx); @@ -1001,7 +998,7 @@ private float handleSingleTextElement(RowElement e, ListPointers pointers, ctx.advances.setAdvanceAt(index, tabWidth); } if (ctx.drawTextConsumer != null && index >= ctx.startCharOffset && index < ctx.endCharOffset) { - ctx.drawTextConsumer.drawText(canvas, chars, index, 1, index, 1, isRtl, offset + localOffset, tabWidth, params, null); + ctx.drawTextConsumer.drawText(canvas, text, index, 1, index, 1, isRtl, offset + localOffset, tabWidth, params, null); } // virtually drawn localOffset += tabWidth; @@ -1016,7 +1013,7 @@ private float handleSingleTextElement(RowElement e, ListPointers pointers, } /** - * Handle a single inline element in an unidirectional run + * Handle a single inline element in a unidirectional run */ private float handleSingleInlineElement(RowElement e, Canvas canvas, float offset, IteratingContext ctx) { @@ -1405,7 +1402,7 @@ public interface DrawTextConsumer { /** * @param span may be null, when tab encountered. */ - void drawText(Canvas canvas, char[] text, int index, int count, int contextIndex, int contextCount, boolean isRtl, + void drawText(Canvas canvas, GetChars text, int index, int count, int contextIndex, int contextCount, boolean isRtl, float horizontalOffset, float width, TextRowParams params, Span span); } diff --git a/editor/src/main/java/io/github/rosemoe/sora/lang/analysis/AsyncIncrementalAnalyzeManager.java b/editor/src/main/java/io/github/rosemoe/sora/lang/analysis/AsyncIncrementalAnalyzeManager.java index c8c71b41a..91776bcfd 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/lang/analysis/AsyncIncrementalAnalyzeManager.java +++ b/editor/src/main/java/io/github/rosemoe/sora/lang/analysis/AsyncIncrementalAnalyzeManager.java @@ -38,6 +38,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import io.github.rosemoe.sora.annotations.Experimental; import io.github.rosemoe.sora.lang.styling.CodeBlock; import io.github.rosemoe.sora.lang.styling.Span; import io.github.rosemoe.sora.lang.styling.SpanFactory; @@ -64,6 +65,36 @@ public abstract class AsyncIncrementalAnalyzeManager extends BaseAnalyzeMa private static int sThreadId = 0; private LooperThread thread; private volatile long runCount; + private final boolean useShallowCopy; + + private static boolean useShallowCopyByDefault = false; + + /** + * Use shallow copy for initial text copying. Memory usage will be much lower than full copy at the beginning. + *

+ * As the text is modified, the memory usage will finally go up to the same usage of full copy when all lines are edited. + *

+ * This function is experimental, and is disabled by default. + */ + @Experimental + public static void setUseShallowCopyByDefault(boolean useShallowCopy) { + useShallowCopyByDefault = useShallowCopy; + } + + /** + * @see #setUseShallowCopyByDefault(boolean) + */ + public static boolean isUseShallowCopyByDefault() { + return useShallowCopyByDefault; + } + + public AsyncIncrementalAnalyzeManager() { + this(isUseShallowCopyByDefault()); + } + + public AsyncIncrementalAnalyzeManager(boolean useShallowCopy) { + this.useShallowCopy = useShallowCopy; + } private synchronized static int nextThreadId() { sThreadId++; @@ -107,7 +138,7 @@ public void rerun() { } var ref = getContentRef(); if (ref != null) { - final var text = ref.getReference().copyText(false); + final var text = ref.getReference().copyText(false, useShallowCopy); text.setUndoEnabled(false); thread = new LooperThread(); thread.setName("AsyncAnalyzer-" + nextThreadId()); @@ -588,6 +619,10 @@ public void run() { } } catch (InterruptedException e) { // ignored + } finally { + if (useShallowCopy && shadowed != null) { + shadowed.release(); + } } } } diff --git a/editor/src/main/java/io/github/rosemoe/sora/lang/completion/snippet/CodeSnippet.java b/editor/src/main/java/io/github/rosemoe/sora/lang/completion/snippet/CodeSnippet.java index 565664e2e..9584d4f93 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/lang/completion/snippet/CodeSnippet.java +++ b/editor/src/main/java/io/github/rosemoe/sora/lang/completion/snippet/CodeSnippet.java @@ -25,8 +25,9 @@ import androidx.annotation.NonNull; +import androidx.collection.IntObjectMap; +import androidx.collection.MutableIntObjectMap; -import io.github.rosemoe.sora.text.TextUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -78,11 +79,11 @@ public List getPlaceholderDefinitions() { @NonNull @Override public CodeSnippet clone() { - var defs = new ArrayList(placeholders.size()); + var definitions = new ArrayList(placeholders.size()); var map = new HashMap(); for (PlaceholderDefinition placeholder : placeholders) { var n = new PlaceholderDefinition(placeholder.getId(), placeholder.getChoices(), placeholder.getElements(), placeholder.getTransform()); - defs.add(n); + definitions.add(n); map.put(placeholder, n); } var itemsClone = new ArrayList(items.size()); @@ -95,13 +96,40 @@ public CodeSnippet clone() { } } } - return new CodeSnippet(itemsClone, defs); + return new CodeSnippet(itemsClone, definitions); + } + + /** + * Specific method for LSP code snippets to generate default insert text when snippet edit + * is not available in editor. + */ + public String toInsertTextForLsp() { + var sb = new StringBuilder(); + var defaultValues = new MutableIntObjectMap(); + for (SnippetItem item : items) { + if (item instanceof PlainTextItem text) { + sb.append(text.getText()); + } else if (item instanceof PlaceholderItem placeholder) { + var id = placeholder.getDefinition().getId(); + if (!defaultValues.contains(id)) { + var value = new StringBuilder(); + for (PlaceHolderElement element : placeholder.getDefinition().getElements()) { + if (element instanceof PlainPlaceholderElement plain) { + value.append(plain.getText()); + } + } + defaultValues.put(id, value.toString()); + } + sb.append(defaultValues.get(id)); + } + } + return sb.toString(); } public static class Builder { private final List definitions; - private List items = new ArrayList<>(); + private final List items = new ArrayList<>(); private int index; public Builder() { @@ -113,9 +141,8 @@ public Builder(@NonNull List definitions) { } public Builder addPlainText(String text) { - if (!items.isEmpty() && items.get(items.size() - 1) instanceof PlainTextItem) { + if (!items.isEmpty() && items.get(items.size() - 1) instanceof PlainTextItem item) { // Merge plain texts - var item = (PlainTextItem) items.get(items.size() - 1); item.setText(item.getText() + text); item.setIndex(item.getStartIndex(), item.getEndIndex() + text.length()); index += text.length(); diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/ContentIO.java b/editor/src/main/java/io/github/rosemoe/sora/text/ContentIO.java index f62616abe..68ab74619 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/text/ContentIO.java +++ b/editor/src/main/java/io/github/rosemoe/sora/text/ContentIO.java @@ -37,7 +37,7 @@ /** * Helper class for creating or saving {@link Content} objects, with minimal extra memory usage when - * processing. + * processing. * * @author Rosemoe */ @@ -45,9 +45,12 @@ public class ContentIO { private final static int BUFFER_SIZE = 16384; + final static int CHAR_BUFFER_SIZE = 1024; + /** * Create a {@link Content} from stream. * The stream will get closed if the operation is successfully done. + * * @param stream Source stream */ @NonNull @@ -58,7 +61,8 @@ public static Content createFrom(@NonNull InputStream stream) throws IOException /** * Create a {@link Content} from stream. * The stream will get closed if the operation is successfully done. - * @param stream Source stream + * + * @param stream Source stream * @param charset Charset for decoding the content */ @NonNull @@ -111,8 +115,8 @@ public static Content createFrom(@NonNull Reader reader) throws IOException { /** * Write the text to the given stream with default charset. Close the stream if {@code closeOnSucceed} is true. * - * @param text Text to be written - * @param stream Output stream + * @param text Text to be written + * @param stream Output stream * @param closeOnSucceed If true, the stream will be closed when operation is successfully */ public static void writeTo(@NonNull Content text, @NonNull OutputStream stream, boolean closeOnSucceed) throws IOException { @@ -122,9 +126,9 @@ public static void writeTo(@NonNull Content text, @NonNull OutputStream stream, /** * Write the text to the given stream with given charset. Close the stream if {@code closeOnSucceed} is true. * - * @param text Text to be written - * @param stream Output stream - * @param charset Charset of output bytes + * @param text Text to be written + * @param stream Output stream + * @param charset Charset of output bytes * @param closeOnSucceed If true, the stream will be closed when operation is successfully */ public static void writeTo(@NonNull Content text, @NonNull OutputStream stream, @NonNull Charset charset, boolean closeOnSucceed) throws IOException { @@ -136,18 +140,24 @@ public static void writeTo(@NonNull Content text, @NonNull OutputStream stream, *

* If you use {@link BufferedWriter}, make sure you set an appropriate buffer size. We recommend using the default size (8192) or larger. * - * @param text Text to be written - * @param writer Output writer - * @param closeOnSucceed If true, the stream will be closed when operation is successfully + * @param text Text to be written + * @param writer Output writer + * @param closeOnSucceed If true, the stream will be closed when operation is successful */ public static void writeTo(@NonNull Content text, @NonNull Writer writer, boolean closeOnSucceed) throws IOException { // Use buffered writer to avoid frequently IO when there are a lot of short lines - final var buffered = (writer instanceof BufferedWriter) ? (BufferedWriter)writer : new BufferedWriter(writer, BUFFER_SIZE); + final var buffered = (writer instanceof BufferedWriter) ? (BufferedWriter) writer : new BufferedWriter(writer, BUFFER_SIZE); + char[] buf = new char[CHAR_BUFFER_SIZE]; try { text.runReadActionsOnLines(0, text.getLineCount() - 1, (Content.ContentLineConsumer2) (index, line, flag) -> { try { // Write line content - buffered.write(line.getBackingCharArray(), 0, line.length()); + for (int i = 0; i < line.length(); ) { + int end = Math.min(i + buf.length, line.length()); + line.getChars(i, end, buf, 0); + buffered.write(buf, 0, end - i); + i = end; + } // Write line feed (the last line has empty line feed) buffered.write(line.getLineSeparator().getChars()); } catch (IOException e) { diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/ContentLine.java b/editor/src/main/java/io/github/rosemoe/sora/text/ContentLine.java index 6a2f2d0f3..e9831c80a 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/text/ContentLine.java +++ b/editor/src/main/java/io/github/rosemoe/sora/text/ContentLine.java @@ -33,16 +33,22 @@ import io.github.rosemoe.sora.annotations.UnsupportedUserUsage; import io.github.rosemoe.sora.text.bidi.BidiRequirementChecker; import io.github.rosemoe.sora.text.bidi.TextBidi; +import io.github.rosemoe.sora.text.string.StringLatin1; +import io.github.rosemoe.sora.text.string.StringUTF16; import io.github.rosemoe.sora.util.ShareableData; public class ContentLine implements CharSequence, GetChars, BidiRequirementChecker, ShareableData { - private char[] value; + private static final byte LATIN1 = 0; + private static final byte UTF16 = 1; + + private byte[] value; + private byte coder; private int length; private int rtlAffectingCount; private LineSeparator lineSeparator; - private AtomicInteger refCount; + private transient AtomicInteger refCount; public ContentLine() { this(true); @@ -54,22 +60,29 @@ public ContentLine(@Nullable CharSequence text) { } public ContentLine(@NonNull ContentLine src) { - this(src.length + 16); length = src.length; + coder = src.coder; + value = coder == LATIN1 ? new byte[src.length + 16] : new byte[StringUTF16.bytesForChars(src.length + 16)]; rtlAffectingCount = src.rtlAffectingCount; lineSeparator = src.lineSeparator; - System.arraycopy(src.value, 0, value, 0, length); + if (coder == LATIN1) { + StringLatin1.copyChars(src.value, 0, value, 0, length); + } else { + StringUTF16.copyChars(src.value, 0, value, 0, length); + } } public ContentLine(int size) { length = 0; - value = new char[size]; + coder = LATIN1; + value = new byte[size]; } private ContentLine(boolean initialize) { if (initialize) { length = 0; - value = new char[32]; + coder = LATIN1; + value = new byte[32]; } } @@ -80,14 +93,61 @@ private void checkIndex(int index) { } private void ensureCapacity(int capacity) { - if (value.length < capacity) { - int newLength = value.length * 2 < capacity ? capacity + 2 : value.length * 2; - char[] newValue = new char[newLength]; - System.arraycopy(value, 0, newValue, 0, length); + if (charCapacity() < capacity) { + int oldCapacity = charCapacity(); + int newLength = oldCapacity * 2 < capacity ? capacity + 2 : oldCapacity * 2; + byte[] newValue = coder == LATIN1 ? new byte[newLength] : new byte[StringUTF16.bytesForChars(newLength)]; + if (coder == LATIN1) { + StringLatin1.copyChars(value, 0, newValue, 0, length); + } else { + StringUTF16.copyChars(value, 0, newValue, 0, length); + } value = newValue; } } + private int charCapacity() { + return coder == LATIN1 ? value.length : value.length >> 1; + } + + private char charAtInternal(int index) { + return coder == LATIN1 ? StringLatin1.getChar(value, index) : StringUTF16.getChar(value, index); + } + + private void putCharInternal(int index, char c) { + if (coder == LATIN1) { + StringLatin1.putChar(value, index, c); + } else { + StringUTF16.putChar(value, index, c); + } + } + + private void copyCharsInternal(byte[] src, int srcBegin, byte[] dst, int dstBegin, int len) { + if (coder == LATIN1) { + StringLatin1.copyChars(src, srcBegin, dst, dstBegin, len); + } else { + StringUTF16.copyChars(src, srcBegin, dst, dstBegin, len); + } + } + + private static boolean requiresUTF16(@NonNull CharSequence s, int start, int end) { + for (int i = start; i < end; i++) { + if (!StringLatin1.canEncode(s.charAt(i))) { + return true; + } + } + return false; + } + + private void toUTF16IfNeeded(int capacity) { + if (coder == LATIN1) { + int oldCapacity = charCapacity(); + int newCapacity = oldCapacity * 2 < capacity ? capacity + 2 : oldCapacity * 2; + value = StringLatin1.inflateToUTF16(value, length, newCapacity); + coder = UTF16; + } + } + /** * Inserts the specified {@code CharSequence} into this sequence. *

@@ -172,12 +232,14 @@ public ContentLine insert(int dstOffset, @Nullable CharSequence s, "start " + start + ", end " + end + ", s.length() " + s.length()); int len = end - start; + if (coder == LATIN1 && requiresUTF16(s, start, end)) { + toUTF16IfNeeded(length + len); + } ensureCapacity(length + len); - System.arraycopy(value, dstOffset, value, dstOffset + len, - length - dstOffset); + copyCharsInternal(value, dstOffset, value, dstOffset + len, length - dstOffset); for (int i = start; i < end; i++) { var ch = s.charAt(i); - value[dstOffset++] = ch; + putCharInternal(dstOffset++, ch); if (TextBidi.couldAffectRtl(ch)) { rtlAffectingCount++; } @@ -188,14 +250,17 @@ public ContentLine insert(int dstOffset, @Nullable CharSequence s, @NonNull public ContentLine insert(int offset, char c) { + if (coder == LATIN1 && !StringLatin1.canEncode(c)) { + toUTF16IfNeeded(length + 1); + } ensureCapacity(length + 1); if (offset < length) { - System.arraycopy(value, offset, value, offset + 1, length - offset); + copyCharsInternal(value, offset, value, offset + 1, length - offset); } if (TextBidi.couldAffectRtl(c)) { rtlAffectingCount++; } - value[offset] = c; + putCharInternal(offset, c); length += 1; return this; } @@ -225,11 +290,11 @@ public ContentLine delete(int start, int end) { int len = end - start; if (len > 0) { for (int i = start; i < end; i++) { - if (TextBidi.couldAffectRtl(value[i])) { + if (TextBidi.couldAffectRtl(charAtInternal(i))) { rtlAffectingCount--; } } - System.arraycopy(value, start + len, value, start, length - end); + copyCharsInternal(value, start + len, value, start, length - end); length -= len; } return this; @@ -269,7 +334,7 @@ public char charAt(int index) { var separator = getLineSeparator(); return separator.getLength() > 0 ? getLineSeparator().getContent().charAt(index - length) : '\n'; } - return value[index]; + return charAtInternal(index); } @Override @@ -280,16 +345,21 @@ public ContentLine subSequence(int start, int end) { if (end < start) { throw new StringIndexOutOfBoundsException("start is greater than end"); } - char[] newValue = new char[end - start + 16]; - System.arraycopy(value, start, newValue, 0, end - start); var res = new ContentLine(false); - res.value = newValue; + res.coder = coder; + if (res.coder == LATIN1) { + res.value = new byte[end - start + 16]; + StringLatin1.copyChars(value, start, res.value, 0, end - start); + } else { + res.value = new byte[StringUTF16.bytesForChars(end - start + 16)]; + StringUTF16.copyChars(value, start, res.value, 0, end - start); + } res.length = end - start; // Compute new value when required if (rtlAffectingCount > 0) { for (int i = 0; i < res.length; i++) { - if (TextBidi.couldAffectRtl(newValue[i])) { + if (TextBidi.couldAffectRtl(res.charAt(i))) { res.rtlAffectingCount++; } } @@ -301,13 +371,17 @@ public ContentLine subSequence(int start, int end) { * A convenient method to append text to a StringBuilder */ public void appendTo(@NonNull StringBuilder sb) { - sb.append(value, 0, length); + if (coder == LATIN1) { + StringLatin1.appendTo(value, length, sb); + } else { + StringUTF16.appendTo(value, length, sb); + } } @Override @NonNull public String toString() { - return new String(value, 0, length); + return coder == LATIN1 ? StringLatin1.newString(value, length) : StringUTF16.newString(value, length); } /** @@ -316,22 +390,14 @@ public String toString() { */ @NonNull public String toStringWithNewline() { - if (value.length == length) { + if (charCapacity() == length) { ensureCapacity(length + 1); } - value[length] = '\n'; - return new String(value, 0, length + 1); - } - - /** - * Get the backing char array of this object. - * The result array should not be modified. - */ - @NonNull - public char[] getBackingCharArray() { - return value; + putCharInternal(length, '\n'); + return coder == LATIN1 ? StringLatin1.newString(value, length + 1) : StringUTF16.newString(value, length + 1); } + @Override public void getChars(int srcBegin, int srcEnd, @NonNull char[] dst, int dstBegin) { if (srcBegin < 0) throw new StringIndexOutOfBoundsException(srcBegin); @@ -339,7 +405,11 @@ public void getChars(int srcBegin, int srcEnd, @NonNull char[] dst, int dstBegin throw new StringIndexOutOfBoundsException(srcEnd); if (srcBegin > srcEnd) throw new StringIndexOutOfBoundsException("srcBegin > srcEnd"); - System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin); + if (coder == LATIN1) { + StringLatin1.getChars(value, srcBegin, srcEnd, dst, dstBegin); + } else { + StringUTF16.getChars(value, srcBegin, srcEnd, dst, dstBegin); + } } public void setLineSeparator(@Nullable LineSeparator separator) { @@ -362,8 +432,9 @@ public LineSeparator getLineSeparator() { public ContentLine copy() { var clone = new ContentLine(false); clone.length = length; - clone.value = new char[value.length]; - System.arraycopy(value, 0, clone.value, 0, length); + clone.coder = coder; + clone.value = new byte[value.length]; + System.arraycopy(value, 0, clone.value, 0, value.length); clone.rtlAffectingCount = rtlAffectingCount; clone.lineSeparator = lineSeparator; return clone; diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/TextUtils.java b/editor/src/main/java/io/github/rosemoe/sora/text/TextUtils.java index 3e8a2852f..e64f55d25 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/text/TextUtils.java +++ b/editor/src/main/java/io/github/rosemoe/sora/text/TextUtils.java @@ -168,7 +168,7 @@ public static String padStart(String src, char padChar, int length) { * * @param line The line to search */ - public static long findLeadingAndTrailingWhitespacePos(ContentLine line) { + public static long findLeadingAndTrailingWhitespacePos(CharSequence line) { return findLeadingAndTrailingWhitespacePos(line, 0, line.length()); } @@ -179,16 +179,15 @@ public static long findLeadingAndTrailingWhitespacePos(ContentLine line) { * @param start Range start (inclusive) * @param end Range end (exclusive) */ - public static long findLeadingAndTrailingWhitespacePos(ContentLine line, int start, int end) { - var buffer = line.getBackingCharArray(); + public static long findLeadingAndTrailingWhitespacePos(CharSequence line, int start, int end) { int leading = start; int trailing = end; - while (leading < end && isWhitespace(buffer[leading])) { + while (leading < end && isWhitespace(line.charAt(leading))) { leading++; } // Skip for space-filled line if (leading != end) { - while (trailing > 0 && isWhitespace(buffer[trailing - 1])) { + while (trailing > 0 && isWhitespace(line.charAt(trailing - 1))) { trailing--; } } diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerIcu.java b/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerIcu.java index 19f45499f..9a7b21e04 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerIcu.java +++ b/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerIcu.java @@ -34,10 +34,10 @@ public class WordBreakerIcu implements WordBreaker { protected final BreakIterator wrappingIterator; - protected final char[] chars; + protected final ContentLine text; public WordBreakerIcu(@NonNull ContentLine text) { - this.chars = text.getBackingCharArray(); + this.text = text; var textIterator = new CharSequenceIterator(text); wrappingIterator = BreakIterator.getLineInstance(); wrappingIterator.setText(textIterator); @@ -45,7 +45,7 @@ public WordBreakerIcu(@NonNull ContentLine text) { public int getOptimizedBreakPoint(int start, int end) { // Merging trailing whitespaces is not supported by editor, so force to break here - if (end > 0 && !Character.isWhitespace(chars[end - 1]) && !wrappingIterator.isBoundary(end)) { + if (end > 0 && !Character.isWhitespace(text.charAt(end - 1)) && !wrappingIterator.isBoundary(end)) { // Break text at last boundary int lastBoundary = wrappingIterator.preceding(end); if (lastBoundary != BreakIterator.DONE) { diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerProgram.java b/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerProgram.java index 704e51318..bbb0cb289 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerProgram.java +++ b/editor/src/main/java/io/github/rosemoe/sora/text/breaker/WordBreakerProgram.java @@ -39,7 +39,7 @@ public WordBreakerProgram(@NonNull ContentLine text) { @Override public int getOptimizedBreakPoint(int start, int end) { int icuResult = super.getOptimizedBreakPoint(start, end); - if (icuResult != end || end <= start || /* end > start */ Character.isWhitespace(chars[end - 1])) { + if (icuResult != end || end <= start || /* end > start */ Character.isWhitespace(text.charAt(end - 1))) { return icuResult; } // The content can be placed on a single row @@ -49,7 +49,7 @@ public int getOptimizedBreakPoint(int start, int end) { // Add extra opportunities for dots int index = end - 1; while (index > start) { - if (chars[index] == '.' && index - 1 >= start && !Character.isDigit(chars[index - 1])) { + if (text.charAt(index) == '.' && index - 1 >= start && !Character.isDigit(text.charAt(index - 1))) { // Break after this dot return index + 1; } diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/string/StringLatin1.java b/editor/src/main/java/io/github/rosemoe/sora/text/string/StringLatin1.java new file mode 100644 index 000000000..49c49f417 --- /dev/null +++ b/editor/src/main/java/io/github/rosemoe/sora/text/string/StringLatin1.java @@ -0,0 +1,72 @@ +/* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + */ +package io.github.rosemoe.sora.text.string; + +import java.nio.charset.StandardCharsets; + +public final class StringLatin1 { + + private StringLatin1() { + } + + public static boolean canEncode(char c) { + return c <= 0x00FF; + } + + public static char getChar(byte[] value, int index) { + return (char) (value[index] & 0xFF); + } + + public static void putChar(byte[] value, int index, char c) { + value[index] = (byte) c; + } + + public static void copyChars(byte[] src, int srcBegin, byte[] dst, int dstBegin, int len) { + System.arraycopy(src, srcBegin, dst, dstBegin, len); + } + + public static void getChars(byte[] value, int srcBegin, int srcEnd, char[] dst, int dstBegin) { + for (int i = srcBegin; i < srcEnd; i++) { + dst[dstBegin++] = getChar(value, i); + } + } + + public static void appendTo(byte[] value, int length, StringBuilder sb) { + for (int i = 0; i < length; i++) { + sb.append(getChar(value, i)); + } + } + + public static String newString(byte[] value, int length) { + return new String(value, 0, length, StandardCharsets.ISO_8859_1); + } + + public static byte[] inflateToUTF16(byte[] value, int length, int newCapacity) { + var utf16 = new byte[StringUTF16.bytesForChars(newCapacity)]; + for (int i = 0; i < length; i++) { + StringUTF16.putChar(utf16, i, getChar(value, i)); + } + return utf16; + } +} diff --git a/editor/src/main/java/io/github/rosemoe/sora/text/string/StringUTF16.java b/editor/src/main/java/io/github/rosemoe/sora/text/string/StringUTF16.java new file mode 100644 index 000000000..a125594a0 --- /dev/null +++ b/editor/src/main/java/io/github/rosemoe/sora/text/string/StringUTF16.java @@ -0,0 +1,67 @@ +/* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + */ +package io.github.rosemoe.sora.text.string; + +public final class StringUTF16 { + + private StringUTF16() { + } + + public static int bytesForChars(int chars) { + return chars << 1; + } + + public static char getChar(byte[] value, int index) { + int i = index << 1; + return (char) (((value[i] & 0xFF) << 8) | (value[i + 1] & 0xFF)); + } + + public static void putChar(byte[] value, int index, char c) { + int i = index << 1; + value[i] = (byte) (c >>> 8); + value[i + 1] = (byte) c; + } + + public static void copyChars(byte[] src, int srcBegin, byte[] dst, int dstBegin, int len) { + System.arraycopy(src, srcBegin << 1, dst, dstBegin << 1, len << 1); + } + + public static void getChars(byte[] value, int srcBegin, int srcEnd, char[] dst, int dstBegin) { + for (int i = srcBegin; i < srcEnd; i++) { + dst[dstBegin++] = getChar(value, i); + } + } + + public static void appendTo(byte[] value, int length, StringBuilder sb) { + for (int i = 0; i < length; i++) { + sb.append(getChar(value, i)); + } + } + + public static String newString(byte[] value, int length) { + var chars = new char[length]; + getChars(value, 0, length, chars, 0); + return new String(chars); + } +} diff --git a/editor/src/main/java/io/github/rosemoe/sora/widget/CodeEditor.java b/editor/src/main/java/io/github/rosemoe/sora/widget/CodeEditor.java index b4a12de6e..38a0ad695 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/widget/CodeEditor.java +++ b/editor/src/main/java/io/github/rosemoe/sora/widget/CodeEditor.java @@ -1440,16 +1440,15 @@ public void setHardwareAcceleratedDrawAllowed(boolean acceleratedDraw) { * @param line The line to search */ protected long findLeadingAndTrailingWhitespacePos(ContentLine line) { - var buffer = line.getBackingCharArray(); int column = line.length(); int leading = 0; int trailing = column; - while (leading < column && isWhitespace(buffer[leading])) { + while (leading < column && isWhitespace(line.charAt(leading))) { leading++; } // Only when this action is needed if (leading != column && (nonPrintableOptions & (FLAG_DRAW_WHITESPACE_INNER | FLAG_DRAW_WHITESPACE_TRAILING)) != 0) { - while (trailing > 0 && isWhitespace(buffer[trailing - 1])) { + while (trailing > 0 && isWhitespace(line.charAt(trailing - 1))) { trailing--; } } @@ -1948,10 +1947,10 @@ public void deleteText() { int line = cur.getLeftLine(); if (props.deleteEmptyLineFast || (props.deleteMultiSpaces != 1 && col > 0 && text.charAt(line, col - 1) == ' ')) { // Check whether selection is in leading spaces - var text = this.text.getLine(cur.getLeftLine()).getBackingCharArray(); + var text = this.text.getLine(cur.getLeftLine()); var inLeading = true; for (int i = col - 1; i >= 0; i--) { - char ch = text[i]; + char ch = text.charAt(i); if (ch != ' ' && ch != '\t') { inLeading = false; break; @@ -1963,7 +1962,7 @@ public void deleteText() { var emptyLine = true; var max = this.text.getColumnCount(line); for (int i = col; i < max; i++) { - char ch = text[i]; + char ch = text.charAt(i); if (ch != ' ' && ch != '\t') { emptyLine = false; break; diff --git a/editor/src/main/java/io/github/rosemoe/sora/widget/EditorRenderer.java b/editor/src/main/java/io/github/rosemoe/sora/widget/EditorRenderer.java index 8a58894ee..878d7880a 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/widget/EditorRenderer.java +++ b/editor/src/main/java/io/github/rosemoe/sora/widget/EditorRenderer.java @@ -40,6 +40,7 @@ import android.graphics.drawable.Drawable; import android.os.Build; import android.os.SystemClock; +import android.text.GetChars; import android.util.Log; import android.util.SparseArray; @@ -48,7 +49,6 @@ import androidx.annotation.RequiresApi; import androidx.collection.MutableIntList; import androidx.collection.MutableLongLongMap; -import androidx.collection.MutableLongObjectMap; import java.util.ArrayList; import java.util.Collections; @@ -1447,14 +1447,14 @@ protected void drawRows(Canvas canvas, float offset, LongArrayList postDrawLineN canvas.save(); canvas.translate(paintingOffset, editor.getRowTopOfText(row) - editor.getOffsetY()); bufferedDrawPoints.setOffsets(paintingOffset, editor.getRowTopOfText(row) - editor.getOffsetY()); - float beginOffset = Math.max(0, paintingOffset); + float beginOffset = Math.max(0, offsetCopy); float endOffset = beginOffset + editor.getWidth(); final var wsLeadingEnd = leadingWhitespaceEnd; final var wsTrailingStart = trailingWhitespaceStart; paintOther.setColor(editor.getColorScheme().getColor(EditorColorScheme.NON_PRINTABLE_CHAR)); tr.iterateDrawTextRegions(rowInf.startColumn, rowInf.endColumn, canvas, beginOffset, endOffset, false, - (Canvas _canvas, char[] text, int index, int count, int contextIndex, int contextCount, boolean isRtl, + (Canvas _canvas, GetChars text, int index, int count, int contextIndex, int contextCount, boolean isRtl, float horizontalOffset, float width, TextRowParams params, Span span) -> { if ((nonPrintableFlags & CodeEditor.FLAG_DRAW_WHITESPACE_LEADING) != 0) { drawWhitespaces(_canvas, tr, text, index, count, contextIndex, contextCount, isRtl, horizontalOffset, width, 0, wsLeadingEnd); @@ -1714,7 +1714,7 @@ protected void drawDiagnosticIndicators(Canvas canvas, float offset) { /** * Draw non-printable characters */ - private void drawWhitespaces(Canvas canvas, TextRow tr, char[] chars, int index, int count, int contextIndex, int contextCount, boolean isRtl, float horizontalOffset, float width, int min, int max) { + private void drawWhitespaces(Canvas canvas, TextRow tr, GetChars chars, int index, int count, int contextIndex, int contextCount, boolean isRtl, float horizontalOffset, float width, int min, int max) { int paintStart = Math.max(index, Math.min(index + count, min)); int paintEnd = Math.max(index, Math.min(index + count, max)); @@ -1723,7 +1723,7 @@ private void drawWhitespaces(Canvas canvas, TextRow tr, char[] chars, int index, float rowCenter = (editor.getRowHeightOfText() / 2f + editor.getRowTopOfText(0)); float offset = isRtl ? horizontalOffset + width : horizontalOffset; while (paintStart < paintEnd) { - char ch = chars[paintStart]; + char ch = chars.charAt(paintStart); int paintCount = 0; boolean paintLine = false; if (ch == ' ' || ch == '\t') { @@ -2302,7 +2302,7 @@ protected void patchTextRegionWithColor(Canvas canvas, float textOffset, int sta paintGeneral.setStyle(useBoldStyle ? Paint.Style.FILL_AND_STROKE : Paint.Style.FILL); paintGeneral.setFakeBoldText(useBoldStyle); - patchTextRegions(canvas, textOffset, start, end, (Canvas canvasLocal, char[] text, int index, int count, int contextIndex, int contextCount, boolean isRtl, + patchTextRegions(canvas, textOffset, start, end, (Canvas canvasLocal, GetChars text, int index, int count, int contextIndex, int contextCount, boolean isRtl, float horizontalOffset, float width, TextRowParams params, Span span) -> { if (span == null) { return; diff --git a/editor/src/main/java/io/github/rosemoe/sora/widget/layout/ViewMeasureHelper.java b/editor/src/main/java/io/github/rosemoe/sora/widget/layout/ViewMeasureHelper.java index babe7f02f..3137c0c3b 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/widget/layout/ViewMeasureHelper.java +++ b/editor/src/main/java/io/github/rosemoe/sora/widget/layout/ViewMeasureHelper.java @@ -60,7 +60,7 @@ public static long getDesiredSize(int widthMeasureSpec, int heightMeasureSpec, f var lines = heightMode != View.MeasureSpec.EXACTLY ? new int[text.getLineCount()] : null; var lineMaxSize = new MutableInt(0); text.runReadActionsOnLines(0, text.getLineCount() - 1, (Content.ContentLineConsumer) (index, line, directions) -> { - int measured = (int) Math.ceil(measurer.measureText(line.getBackingCharArray(), 0, line.length(), paint)); + int measured = (int) Math.ceil(measurer.measureText(line, 0, line.length(), paint)); if (measured > lineMaxSize.value) { lineMaxSize.value = measured; } @@ -91,7 +91,7 @@ public static long getDesiredSize(int widthMeasureSpec, int heightMeasureSpec, f rowCount.value = text.length(); } else { text.runReadActionsOnLines(0, text.getLineCount() - 1, (Content.ContentLineConsumer) (index, line, directions) -> { - int measured = (int) Math.ceil(measurer.measureText(line.getBackingCharArray(), 0, line.length(), paint)); + int measured = (int) Math.ceil(measurer.measureText(line, 0, line.length(), paint)); rowCount.value += Math.max(1, Math.ceil(1.0 * measured / availableSize)); }); } @@ -103,7 +103,7 @@ public static long getDesiredSize(int widthMeasureSpec, int heightMeasureSpec, f if (widthMode != View.MeasureSpec.EXACTLY) { var lineMaxSize = new MutableInt(0); text.runReadActionsOnLines(0, text.getLineCount() - 1, (Content.ContentLineConsumer) (index, line, directions) -> { - int measured = (int) Math.ceil(measurer.measureText(line.getBackingCharArray(), 0, line.length(), paint)); + int measured = (int) Math.ceil(measurer.measureText(line, 0, line.length(), paint)); if (measured > lineMaxSize.value) { lineMaxSize.value = measured; } diff --git a/editor/src/test/java/io/github/rosemoe/sora/text/ContentLineTest.kt b/editor/src/test/java/io/github/rosemoe/sora/text/ContentLineTest.kt new file mode 100644 index 000000000..c709321a2 --- /dev/null +++ b/editor/src/test/java/io/github/rosemoe/sora/text/ContentLineTest.kt @@ -0,0 +1,83 @@ +/******************************************************************************* + * sora-editor - the awesome code editor for Android + * https://github.com/Rosemoe/sora-editor + * Copyright (C) 2020-2026 Rosemoe + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 + * USA + * + * Please contact Rosemoe by email 2073412493@qq.com if you need + * additional information or have any questions + ******************************************************************************/ + +package io.github.rosemoe.sora.text + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ContentLineTest { + + @Test + fun `latin1 path should preserve content and char operations`() { + val line = ContentLine() + line.insert(0, "ab") + line.insert(2, '\u00FF') + line.insert(3, "cd") + + assertThat(line.length).isEqualTo(5) + assertThat(line.toString()).isEqualTo("ab\u00FFcd") + assertThat(line[2]).isEqualTo('\u00FF') + + val out = CharArray(line.length) + line.getChars(0, line.length, out, 0) + assertThat(String(out)).isEqualTo("ab\u00FFcd") + + val sb = StringBuilder() + line.appendTo(sb) + assertThat(sb.toString()).isEqualTo("ab\u00FFcd") + } + + @Test + fun `utf16 upgrade should happen when inserting non latin1 char`() { + val line = ContentLine("hello") + line.insert(5, '中') + line.insert(0, "前") + + assertThat(line.toString()).isEqualTo("前hello中") + assertThat(line[0]).isEqualTo('前') + assertThat(line[6]).isEqualTo('中') + + val out = CharArray(line.length) + line.getChars(0, line.length, out, 0) + assertThat(String(out)).isEqualTo("前hello中") + + line.delete(1, 6) + assertThat(line.toString()).isEqualTo("前中") + } + + @Test + fun `subSequence and copy should work after utf16 upgrade`() { + val line = ContentLine("a中b文c") + + val sub = line.subSequence(1, 4) + assertThat(sub.toString()).isEqualTo("中b文") + + val copied = line.copy() + copied.insert(copied.length, '末') + assertThat(copied.toString()).isEqualTo("a中b文c末") + assertThat(line.toString()).isEqualTo("a中b文c") + } + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce3af9e48..95ad91f8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "9.0.1" kotlin = "2.3.10" tsBinding = "4.3.2" -lsp4j = "0.24.0" +lsp4j = "1.0.0" androidxAnnotation = "1.9.1" [libraries] diff --git a/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/MonarchAnalyzer.kt b/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/MonarchAnalyzer.kt index d49fce70e..1fd1b58d4 100644 --- a/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/MonarchAnalyzer.kt +++ b/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/MonarchAnalyzer.kt @@ -184,7 +184,7 @@ class MonarchAnalyzer( // It's safe here to use raw data because the Content is only held by this thread val length = model.getColumnCount(foldingStartLine) - val chars = model.getLine(foldingStartLine).backingCharArray + val chars = model.getLine(foldingStartLine) codeBlock.startColumn = IndentRange.computeStartColumn( @@ -294,7 +294,7 @@ class MonarchAnalyzer( line, 0 ), IndentRange.computeIndentLevel( - (lineC as ContentLine).backingCharArray, line.length - 1, language.tabSize + lineC, line.length - 1, language.tabSize ), identifiers ), null, tokens diff --git a/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/folding/IndentRange.kt b/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/folding/IndentRange.kt index 09bdb7732..402b3ac55 100644 --- a/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/folding/IndentRange.kt +++ b/language-monarch/src/main/java/io/github/rosemoe/sora/langs/monarch/folding/IndentRange.kt @@ -35,7 +35,7 @@ object IndentRange { // START sora-editor note // Change String to char[] and int // END sora-editor note - fun computeStartColumn(line: CharArray, len: Int, tabSize: Int): Int { + fun computeStartColumn(line: CharSequence, len: Int, tabSize: Int): Int { var column = 0 var i = 0 @@ -64,7 +64,7 @@ object IndentRange { * - -1 => the line consists of whitespace * - otherwise => the indent level is returned value */ - fun computeIndentLevel(line: CharArray, len: Int, tabSize: Int): Int { + fun computeIndentLevel(line: CharSequence, len: Int, tabSize: Int): Int { var indent = 0 var i = 0 diff --git a/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/TextMateAnalyzer.java b/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/TextMateAnalyzer.java index ef5832429..76adfc5d6 100644 --- a/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/TextMateAnalyzer.java +++ b/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/TextMateAnalyzer.java @@ -190,7 +190,7 @@ public void analyzeCodeBlocks(Content model, ArrayList blocks, CodeBl // It's safe here to use raw data because the Content is only held by this thread var length = model.getColumnCount(startLine); - var chars = model.getLine(startLine).getBackingCharArray(); + var chars = model.getLine(startLine); codeBlock.startColumn = IndentRange.computeStartColumn(chars, length, language.getTabSize()); codeBlock.endColumn = codeBlock.startColumn; @@ -253,7 +253,7 @@ public synchronized LineTokenizeResult tokenizeLine(CharSequence tokens.add(span); } - return new LineTokenizeResult<>(new MyState(lineTokens.getRuleStack(), cachedRegExp == null ? null : cachedRegExp.search(OnigString.of(line), 0), IndentRange.computeIndentLevel(((ContentLine) lineC).getBackingCharArray(), line.length() - 1, language.getTabSize()), identifiers), null, tokens); + return new LineTokenizeResult<>(new MyState(lineTokens.getRuleStack(), cachedRegExp == null ? null : cachedRegExp.search(OnigString.of(line), 0), IndentRange.computeIndentLevel(lineC, line.length() - 1, language.getTabSize()), identifiers), null, tokens); } @Override diff --git a/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/folding/IndentRange.java b/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/folding/IndentRange.java index 084dc1af2..c601f498c 100644 --- a/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/folding/IndentRange.java +++ b/language-textmate/src/main/java/io/github/rosemoe/sora/langs/textmate/folding/IndentRange.java @@ -42,12 +42,12 @@ public class IndentRange { // Change String to char[] and int // END sora-editor note - public static int computeStartColumn(char[] line, int len, int tabSize) { + public static int computeStartColumn(CharSequence line, int len, int tabSize) { int column = 0; int i = 0; while (i < len) { - char chCode = line[i]; + char chCode = line.charAt(i); if (chCode == ' ') { column++; } else if (chCode == '\t') { @@ -71,12 +71,12 @@ public static int computeStartColumn(char[] line, int len, int tabSize) { * - -1 => the line consists of whitespace * - otherwise => the indent level is returned value */ - public static int computeIndentLevel(char[] line, int len, int tabSize) { + public static int computeIndentLevel(CharSequence line, int len, int tabSize) { int indent = 0; int i = 0; while (i < len) { - char chCode = line[i]; + char chCode = line.charAt(i); if (chCode == ' ') { indent++; } else if (chCode == '\t') {