diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 7e8b82925..d45cb7a46 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.12.1 + uses: styfle/cancel-workflow-action@0.13.0 with: access_token: ${{ github.token }} diff --git a/app/src/main/java/io/github/rosemoe/sora/app/MainActivity.kt b/app/src/main/java/io/github/rosemoe/sora/app/MainActivity.kt index 0a848579d..28b422cdb 100644 --- a/app/src/main/java/io/github/rosemoe/sora/app/MainActivity.kt +++ b/app/src/main/java/io/github/rosemoe/sora/app/MainActivity.kt @@ -52,6 +52,7 @@ import io.github.rosemoe.sora.app.lsp.LspTestJavaActivity import io.github.rosemoe.sora.app.tests.TestActivity import io.github.rosemoe.sora.event.ContentChangeEvent import io.github.rosemoe.sora.event.EditorKeyEvent +import io.github.rosemoe.sora.event.InlayHintClickEvent import io.github.rosemoe.sora.event.KeyBindingEvent import io.github.rosemoe.sora.event.PublishSearchResultEvent import io.github.rosemoe.sora.event.SelectionChangeEvent @@ -91,6 +92,7 @@ import io.github.rosemoe.sora.utils.codePointStringAt import io.github.rosemoe.sora.utils.escapeCodePointIfNecessary import io.github.rosemoe.sora.utils.toast import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.EditorSearcher import io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions import io.github.rosemoe.sora.widget.SelectionMovement import io.github.rosemoe.sora.widget.component.EditorAutoCompletion @@ -240,6 +242,9 @@ class MainActivity : AppCompatActivity() { subscribeAlways { toast(R.string.tip_side_icon) } + subscribeAlways { + toast(R.string.tip_inlay_hint) + } subscribeAlways { event -> Log.d( TAG, @@ -256,6 +261,7 @@ class MainActivity : AppCompatActivity() { } } + searcher.replaceOptions = EditorSearcher.ReplaceOptions(true) // Handle span interactions EditorSpanInteractionHandler(this) getComponent() @@ -285,6 +291,7 @@ class MainActivity : AppCompatActivity() { updateBtnState() switchThemeIfRequired(this, binding.editor) + computeSearchOptions() } /** diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a09ce5403..5113048f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -89,6 +89,7 @@ 物理键盘连接时隐藏软键盘 日志已删除。 点击了侧边按钮 + 点击了嵌入提示 选择LSP活动 是否要打开以Kotlin编写的LSP界面? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51e0e412a..9ed76c3f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,7 @@ Hide soft kbd if hard kbd available Log removed. Side icon clicked. + Inlay hint clicked. Select LSP Activity Do you want to open LspActivity written in Kotlin? Yes diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 000000000..40ca89850 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,27 @@ +/******************************************************************************* + * 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 + ******************************************************************************/ + +tasks.register("clean").configure { + delete(rootProject.layout.buildDirectory) +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 386240c02..630c38074 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,7 +68,7 @@ fun Project.configureAndroidAndKotlin() { extensions.findByType()?.apply { compilerOptions { - languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2 jvmTarget = JvmTarget.JVM_17 } } diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/LspEditorUIDelegate.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/LspEditorUIDelegate.kt index ce46cb4ba..0de300e8b 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/LspEditorUIDelegate.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/LspEditorUIDelegate.kt @@ -197,7 +197,7 @@ internal class LspEditorUIDelegate(private val editor: LspEditor) { val editorInstance = currentEditorRef.get() ?: return if (highlights.isNullOrEmpty()) { - editorInstance.highlightTexts = null + editorInstance.post { editorInstance.highlightTexts = null } return } @@ -228,7 +228,7 @@ internal class LspEditorUIDelegate(private val editor: LspEditor) { ) } - editorInstance.highlightTexts = container + editorInstance.post { editorInstance.highlightTexts = container } } fun showInlayHints(inlayHints: List?) { @@ -256,7 +256,7 @@ internal class LspEditorUIDelegate(private val editor: LspEditor) { val hasDocumentColors = !cachedDocumentColors.isNullOrEmpty() if (!hasInlayHints && !hasDocumentColors) { - editorInstance.inlayHints = null + editorInstance.post { editorInstance.inlayHints = null } return } @@ -264,7 +264,7 @@ internal class LspEditorUIDelegate(private val editor: LspEditor) { cachedInlayHints?.inlayHintToDisplay()?.forEach(container::add) cachedDocumentColors?.colorInfoToDisplay()?.forEach(container::add) - editorInstance.inlayHints = container + editorInstance.post { editorInstance.inlayHints = container } } private fun resetInlinePresentations() { @@ -272,7 +272,7 @@ internal class LspEditorUIDelegate(private val editor: LspEditor) { cachedDocumentColors = null currentEditorRef.get()?.let { if (it.inlayHints != null) { - it.inlayHints = null + it.post { it.inlayHints = null } } } } diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/completion/LspCompletionItem.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/completion/LspCompletionItem.kt index fe2cedd0f..a693ff1de 100644 --- a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/completion/LspCompletionItem.kt +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/editor/completion/LspCompletionItem.kt @@ -26,10 +26,13 @@ package io.github.rosemoe.sora.lsp.editor.completion import io.github.rosemoe.sora.lang.completion.CompletionItemKind import io.github.rosemoe.sora.lang.completion.SimpleCompletionIconDrawer.draw +import io.github.rosemoe.sora.lang.completion.SimpleCompletionIconDrawer.drawColorSpan +import io.github.rosemoe.sora.lang.completion.SimpleCompletionIconDrawer.drawFileFolder import io.github.rosemoe.sora.lang.completion.snippet.parser.CodeSnippetParser import io.github.rosemoe.sora.lsp.editor.LspEventManager import io.github.rosemoe.sora.lsp.events.EventType import io.github.rosemoe.sora.lsp.events.document.applyEdits +import io.github.rosemoe.sora.lsp.utils.ColorUtils import io.github.rosemoe.sora.lsp.utils.asLspPosition import io.github.rosemoe.sora.lsp.utils.createPosition import io.github.rosemoe.sora.lsp.utils.createRange @@ -38,10 +41,10 @@ import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.util.Logger import io.github.rosemoe.sora.widget.CodeEditor import org.eclipse.lsp4j.CompletionItem +import org.eclipse.lsp4j.CompletionItemTag import org.eclipse.lsp4j.InsertTextFormat import org.eclipse.lsp4j.TextEdit - class LspCompletionItem( private val completionItem: CompletionItem, private val eventManager: LspEventManager, @@ -50,7 +53,6 @@ class LspCompletionItem( completionItem.label, completionItem.detail ) { - init { this.prefixLength = prefixLength kind = @@ -60,10 +62,48 @@ class LspCompletionItem( sortText = completionItem.sortText filterText = completionItem.filterText val labelDetails = completionItem.labelDetails - if (labelDetails != null && labelDetails.description?.isNotEmpty() == true) { - desc = labelDetails.description + if (labelDetails != null) { + if (labelDetails.description?.isNotEmpty() == true) { + desc = labelDetails.description + } + detail = labelDetails.detail + } + val tags = completionItem.tags + if (tags != null) { + deprecated = tags.contains(CompletionItemTag.Deprecated) + } + + val fileIcon = when { + kind == CompletionItemKind.File || kind == CompletionItemKind.Folder -> { + label?.let { drawFileFolder(it.toString()) } ?: desc?.let { drawFileFolder(it.toString()) } + } + else -> null + } + + icon = fileIcon ?: run { + val colorValue = extractColor() + if (kind == CompletionItemKind.Color && colorValue != null) { + drawColorSpan(colorValue) + } else { + draw(kind ?: CompletionItemKind.Text) + } } - icon = draw(kind ?: CompletionItemKind.Text) + } + + fun extractColor(): Int? { + val labelColor = label?.let { ColorUtils.parseColor(it.toString()) } + val detailColor = desc?.let { ColorUtils.parseColor(it.toString()) } + + val documentation = completionItem.documentation?.let { + if (it.isLeft) it.left else it.right.value + } + val documentationColor = documentation?.let { ColorUtils.parseColor(it) } + + if (documentationColor != null && detailColor == null && labelColor == null && desc == null) { + desc = documentation + } + + return labelColor ?: detailColor ?: documentationColor } override fun performCompletion(editor: CodeEditor, text: Content, position: CharPosition) { @@ -84,7 +124,10 @@ class LspCompletionItem( if (completionItem.textEdit != null && completionItem.textEdit.isLeft) { textEdit = completionItem.textEdit.left } else if (completionItem.textEdit?.isRight == true) { - textEdit = TextEdit(completionItem.textEdit.right.insert, completionItem.textEdit.right.newText) + textEdit = TextEdit( + completionItem.textEdit.right.insert, + completionItem.textEdit.right.newText + ) } if (textEdit.newText == null && completionItem.label != null) { @@ -164,5 +207,3 @@ class LspCompletionItem( // do nothing } } - - 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 8cb745d09..bfbb4b483 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 @@ -140,4 +140,4 @@ class DocumentColorEvent : AsyncEventListener() { @get:Experimental val EventType.documentColor: String - get() = "textDocument/documentColor" \ No newline at end of file + get() = "textDocument/documentColor" 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 9e251a288..55f0a43ee 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 @@ -164,4 +164,4 @@ class InlayHintEvent : AsyncEventListener() { @get:Experimental val EventType.inlayHint: String - get() = "textDocument/inlayHint" \ No newline at end of file + get() = "textDocument/inlayHint" diff --git a/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/ColorUtils.kt b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/ColorUtils.kt new file mode 100644 index 000000000..82e611e19 --- /dev/null +++ b/editor-lsp/src/main/java/io/github/rosemoe/sora/lsp/utils/ColorUtils.kt @@ -0,0 +1,208 @@ +/******************************************************************************* + * 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.lsp.utils + +import android.annotation.SuppressLint +import android.graphics.Color + +/** + * Utility object for parsing and converting colors from various formats. + * + * Supported formats: + * - Hex colors: #RGB, #RRGGBB, #RGBA, #RRGGBBAA + * - RGB/RGBA colors: rgb(r,g,b), rgba(r,g,b,a) + * - HSL/HSLA colors: hsl(h,s%,l%), hsla(h,s%,l%,a) + * + * @author KonerDev + */ +object ColorUtils { + /** + * Converts HSL color values to RGB. + * + * @param h Hue (0–1) + * @param s Saturation (0–1) + * @param l Lightness (0–1) + * @return RGB values as an array `[r, g, b]` in range 0–255 + */ + // Algorithm from https://stackoverflow.com/a/53095879 + fun hslToRgb(h: Float, s: Float, l: Float): IntArray { + val r: Float + val g: Float + val b: Float + + if (s == 0f) { + b = l + g = b + r = g + } else { + val q = if (l < 0.5f) l * (1 + s) else l + s - l * s + val p = 2 * l - q + r = hueToRgb(p, q, h + 1f / 3f) + g = hueToRgb(p, q, h) + b = hueToRgb(p, q, h - 1f / 3f) + } + return intArrayOf((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt()) + } + + /** + * Helper method for converting hue to an RGB channel value. + */ + private fun hueToRgb(p: Float, q: Float, t: Float): Float { + var t = t + if (t < 0f) t += 1f + if (t > 1f) t -= 1f + if (t < 1f / 6f) return p + (q - p) * 6f * t + if (t < 1f / 2f) return q + if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f + return p + } + + /** + * Parses a HSL/HSLA color string into an Android [Color] integer. + * + * @param hsl HSL or HSLA string + * @return Parsed color integer, or null if input is invalid + */ + fun parseHsl(hsl: String): Int? { + val hslRegex = Regex("""hsla?\(\s*([0-9.]+)\s*(?:,|\s)\s*([0-9.]+%)\s*(?:,|\s)\s*([0-9.]+%)(?:\s*[/,]\s*([0-9.]+%?))?\s*\)""") + val match = hslRegex.matchEntire(hsl) ?: return null + + fun parseHue(value: String): Float { + val h = value.toFloat() + return ((h % 360f) + 360f) % 360f / 360f + } + + fun parsePercent(value: String): Float { + return value.dropLast(1).toFloat().coerceIn(0f, 100f) / 100f + } + + fun parseAlpha(value: String?): Int { + if (value.isNullOrEmpty()) return 255 + return if (value.endsWith("%")) { + (value.dropLast(1).toFloat().coerceIn(0f, 100f) * 255 / 100).toInt() + } else { + (value.toFloat().coerceIn(0f, 1f) * 255).toInt() + } + } + + val h = parseHue(match.groupValues[1]) + val s = parsePercent(match.groupValues[2]) + val l = parsePercent(match.groupValues[3]) + val a = parseAlpha(match.groupValues[4]) + + val rgb = hslToRgb(h, s, l) + return Color.argb(a, rgb[0], rgb[1], rgb[2]) + } + + /** + * Parses an RGB/RGBA color string into an Android [Color] integer. + * + * @param rgb RGB or RGBA string + * @return Parsed color integer, or null if input is invalid + */ + fun parseRgb(rgb: String): Int? { + val rgbRegex = Regex("""rgba?\(\s*(\d{1,3}%?)\s*(?:,|\s)\s*(\d{1,3}%?)\s*(?:,|\s)\s*(\d{1,3}%?)(?:\s*[/,]\s*([0-9.]+%?))?\s*\)""") + val match = rgbRegex.matchEntire(rgb) ?: return null + + fun parseChannel(value: String): Int { + return if (value.endsWith("%")) { + (value.dropLast(1).toFloat().coerceIn(0f, 100f) * 255 / 100).toInt() + } else { + value.toInt().coerceIn(0, 255) + } + } + + fun parseAlpha(value: String?): Int { + if (value.isNullOrEmpty()) return 255 + return if (value.endsWith("%")) { + (value.dropLast(1).toFloat().coerceIn(0f, 100f) * 255 / 100).toInt() + } else { + (value.toFloat().coerceIn(0f, 1f) * 255).toInt() + } + } + + val r = parseChannel(match.groupValues[1]) + val g = parseChannel(match.groupValues[2]) + val b = parseChannel(match.groupValues[3]) + val a = parseAlpha(match.groupValues[4]) + + return Color.argb(a, r, g, b) + } + + /** + * Parses a hexadecimal color string into an Android [Color] integer. + * + * Accepts: + * - `#RGB` + * - `#RGBA` + * - `#RRGGBB` + * - `#RRGGBBAA` + * + * @param hex Hexadecimal color string + * @return Android color integer, or null if input is invalid + */ + @SuppressLint("UseKtx") + fun parseHex(hex: String): Int? { + val normalizedHex = normalizeHex(hex) + return runCatching { Color.parseColor(normalizedHex) }.getOrNull() + } + + /** + * Normalizes a hex color string to a format compatible with Android [Color.parseColor]. + * + * - `#RGB` → `#RRGGBB` + * - `#RGBA` → `#AARRGGBB` + * - `#RRGGBBAA` → `#AARRGGBB` + * + * @param hex Original hex color string + * @return Normalized hex string + */ + fun normalizeHex(hex: String): String { + val hexValue = hex.removePrefix("#") + return when (hexValue.length) { + // #RGB -> #RRGGBB + 3 -> "#" + hexValue.map { "$it$it" }.joinToString("") + + // #RGBA -> #AARRGGBB + 4 -> "#" + hexValue[3] + hexValue[3] + hexValue[0] + hexValue[0] + hexValue[1] + hexValue[1] + hexValue[2] + hexValue[2] + + // #RRGGBBAA -> #AARRGGBB + 8 -> "#" + hexValue.substring(6, 8) + hexValue.take(6) + + // Unchanged + else -> hex + } + } + + /** + * Parses a color string in any supported format (HSL, RGB, or Hex) into an Android [Color] integer. + * + * @param color Color string in any supported format + * @return Android color integer, or null if input is invalid + */ + fun parseColor(color: String): Int? { + return parseHsl(color) ?: parseRgb(color) ?: parseHex(color) + } +} \ No newline at end of file diff --git a/editor-lsp/src/main/res/layout/lsp_diagnostic_tooltip_window.xml b/editor-lsp/src/main/res/layout/lsp_diagnostic_tooltip_window.xml index 15f44701a..3fd011f50 100644 --- a/editor-lsp/src/main/res/layout/lsp_diagnostic_tooltip_window.xml +++ b/editor-lsp/src/main/res/layout/lsp_diagnostic_tooltip_window.xml @@ -1,4 +1,5 @@ - - + - + - + - + - + - + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ccc09a64..f240b419c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.0.0" -kotlin = "2.3.0" +kotlin = "2.2.21" tsBinding = "4.3.2" lsp4j = "0.24.0" androidxAnnotation = "1.9.1" @@ -41,5 +41,4 @@ tests-robolectric = { module = "org.robolectric:robolectric", version = "4.16" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } publish = { id = "com.vanniktech.maven.publish.base", version = "0.35.0" } diff --git a/oniguruma-native/src/main/cpp/CMakeLists.txt b/oniguruma-native/src/main/cpp/CMakeLists.txt index 0067a6b96..f4e314484 100644 --- a/oniguruma-native/src/main/cpp/CMakeLists.txt +++ b/oniguruma-native/src/main/cpp/CMakeLists.txt @@ -5,7 +5,8 @@ project("oniguruma-binding") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_FLAGS "-fvisibility=hidden") -add_subdirectory(oniguruma) +set(BUILD_TEST OFF CACHE BOOL "Disable oniguruma tests" FORCE) +add_subdirectory(oniguruma EXCLUDE_FROM_ALL) add_library("oniguruma-binding" SHARED binding.cpp)