Skip to content

Commit c14733b

Browse files
ADFA-4436 Add a generic editor decoration provider extension point (#1448)
* ADFA-4436 Add a generic editor decoration provider extension point Add an additive, feature-agnostic way for plugins to contribute foreground colors to editor text, layered on top of the normal syntax highlighting. - plugin-api: new EditorDecorationProvider with a single method, decorate(text, start, end, isDark) -> List<DecorationSpan>. The provider owns all of its own logic and returns colored ranges; because the only output is a list of spans, it can never replace or suppress highlighting. - plugin-manager: getEnabledEditorDecorationProviders() collector. - common: EditorDecorationRegistry holds the active providers + current theme, read by the editor pipeline and populated by the app. Keeps the editor module free of any plugin-manager dependency (common now depends on the leaf plugin-api module). - editor-treesitter: after building a region's base spans, LineSpansGenerator calls each provider and merges the returned spans as foreground-only overrides, preserving base styles and the non-overlap invariant. The IDE is entirely feature-agnostic here. - app: EditorDecorationBridge registers the enabled providers and current theme into the registry and posts ColorSchemeInvalidatedEvent to repaint; a ComponentCallbacks listener flips decorations on day/night changes live. The dead EditorExtension.provideSyntaxHighlighting() hook (no call sites, replaces highlighting, no RGB/depth) is left untouched. A companion rainbow-brackets plugin (separate repo) implements this provider end to end; device-verified: depth-cycled bracket colors in Java, string and comment exclusion, day/night palettes, live theme toggle, revert on disable. * ADFA-4436 Use imports and early return in editor decoration code - Replace fully-qualified EditorDecorationBridge / EditorDecorationProvider references with imports across the app and plugin-manager call sites. - Invert the registration guard in EditorDecorationBridge.init() to an early return, dropping a level of nesting while preserving behavior. * ADFA-4436 Make editor decoration registration guard atomic Claim the one-time listener registration with AtomicBoolean.compareAndSet so concurrent init() callers can't double-register, and roll the flag back on failure so a later init() can retry instead of being permanently stuck.
1 parent 939b191 commit c14733b

8 files changed

Lines changed: 311 additions & 1 deletion

File tree

app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.itsaky.androidide.preferences.internal.GeneralPreferences
1818
import com.itsaky.androidide.resources.localization.LocaleProvider
1919
import com.itsaky.androidide.ui.themes.IDETheme
2020
import com.itsaky.androidide.ui.themes.IThemeManager
21+
import com.itsaky.androidide.utils.EditorDecorationBridge
2122
import com.itsaky.androidide.utils.Environment
2223
import com.itsaky.androidide.utils.FeatureFlags
2324
import com.itsaky.androidide.utils.FileUtil
@@ -329,6 +330,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
329330
GlobalScope.launch {
330331
try {
331332
pluginManager?.loadPlugins()
333+
EditorDecorationBridge.init()
332334
logger.info("Plugin system initialized successfully")
333335
} catch (e: Exception) {
334336
logger.error("Failed to load plugins", e)
@@ -380,6 +382,7 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
380382
}
381383
} else if (event.key == GeneralPreferences.UI_MODE && GeneralPreferences.uiMode != AppCompatDelegate.getDefaultNightMode()) {
382384
AppCompatDelegate.setDefaultNightMode(GeneralPreferences.uiMode)
385+
EditorDecorationBridge.refresh()
383386
} else if (event.key == GeneralPreferences.SELECTED_LOCALE) {
384387
// Use empty locale list if the locale has been reset to 'System Default'
385388
val selectedLocale = GeneralPreferences.selectedLocale
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* This file is part of AndroidIDE.
3+
*
4+
* AndroidIDE is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* AndroidIDE is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.itsaky.androidide.utils
19+
20+
import android.content.ComponentCallbacks
21+
import android.content.res.Configuration
22+
import androidx.appcompat.app.AppCompatDelegate
23+
import com.itsaky.androidide.app.BaseApplication
24+
import com.itsaky.androidide.eventbus.events.editor.ColorSchemeInvalidatedEvent
25+
import com.itsaky.androidide.plugins.manager.core.PluginManager
26+
import com.itsaky.androidide.syntax.decoration.EditorDecorationRegistry
27+
import org.greenrobot.eventbus.EventBus
28+
import org.slf4j.LoggerFactory
29+
import java.util.concurrent.atomic.AtomicBoolean
30+
31+
/**
32+
* Bridges editor decoration providers contributed by enabled plugins into the editor's
33+
* [EditorDecorationRegistry].
34+
*
35+
* Keeps the dependency direction clean: the app depends on both the plugin manager and `common`,
36+
* so it populates the registry that the low-level editor pipeline reads — the editor never depends
37+
* on the plugin manager. The bridge is feature-agnostic; it knows nothing about what any provider
38+
* decorates.
39+
*
40+
* Call [refresh] whenever plugins are (re)loaded/enabled/disabled. [init] additionally tracks the
41+
* day/night theme so decorations repaint with the right colors the moment the theme flips.
42+
*/
43+
object EditorDecorationBridge {
44+
45+
private val log = LoggerFactory.getLogger(EditorDecorationBridge::class.java)
46+
47+
private val registered = AtomicBoolean(false)
48+
49+
/** Last seen UI night-mode bit, so we only react when day/night actually flips. */
50+
private var lastNightMode = Int.MIN_VALUE
51+
52+
/**
53+
* One-time setup: register a configuration listener so a system (or in-app) day/night flip
54+
* repaints editor decorations with the matching theme immediately — no restart required — then
55+
* do an initial [refresh]. Safe to call more than once; only the first call registers.
56+
*/
57+
@JvmStatic
58+
fun init() {
59+
// Atomically claim the one-time registration. Only the winning thread registers; if it
60+
// fails, reset the flag so a later init() can retry.
61+
if (registered.compareAndSet(false, true)) {
62+
try {
63+
val app = BaseApplication.baseInstance
64+
lastNightMode = app.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
65+
app.registerComponentCallbacks(object : ComponentCallbacks {
66+
override fun onConfigurationChanged(newConfig: Configuration) {
67+
val night = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK
68+
if (night != lastNightMode) {
69+
lastNightMode = night
70+
refresh()
71+
}
72+
}
73+
74+
override fun onLowMemory() {}
75+
})
76+
} catch (t: Throwable) {
77+
registered.set(false)
78+
log.error("Failed to register editor decoration theme listener", t)
79+
}
80+
}
81+
refresh()
82+
}
83+
84+
/**
85+
* Recompute the active decoration providers and current theme, then force open editors to
86+
* re-highlight.
87+
*/
88+
@JvmStatic
89+
fun refresh() {
90+
try {
91+
val providers = PluginManager.getInstance()
92+
?.getEnabledEditorDecorationProviders()
93+
?: emptyList()
94+
95+
EditorDecorationRegistry.isDark = isDarkTheme()
96+
EditorDecorationRegistry.update(providers)
97+
EventBus.getDefault().post(ColorSchemeInvalidatedEvent())
98+
} catch (t: Throwable) {
99+
log.error("Failed to refresh editor decoration providers", t)
100+
}
101+
}
102+
103+
private fun isDarkTheme(): Boolean {
104+
// Honor an explicit user theme choice immediately (config may not have propagated yet
105+
// right after a theme toggle); fall back to the resource config for "follow system".
106+
when (AppCompatDelegate.getDefaultNightMode()) {
107+
AppCompatDelegate.MODE_NIGHT_YES -> return true
108+
AppCompatDelegate.MODE_NIGHT_NO -> return false
109+
}
110+
val uiMode = BaseApplication.baseInstance.resources.configuration.uiMode
111+
return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
112+
}
113+
}

app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEffect
1313
import com.itsaky.androidide.ui.models.PluginManagerUiEvent
1414
import com.itsaky.androidide.ui.models.PluginManagerUiState
1515
import com.itsaky.androidide.ui.models.PluginOperation
16+
import com.itsaky.androidide.utils.EditorDecorationBridge
1617
import com.itsaky.androidide.utils.UriFileImporter
1718
import kotlinx.coroutines.Dispatchers
1819
import kotlinx.coroutines.channels.Channel
@@ -122,6 +123,9 @@ class PluginManagerViewModel(
122123
)
123124
}
124125

126+
// Keep the editor decoration providers in sync with the enabled plugin set.
127+
EditorDecorationBridge.refresh()
128+
125129
_currentOperation.value = PluginOperation.None
126130
}
127131
}

common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies {
3434
api(projects.eventbusAndroid)
3535
api(projects.eventbusEvents)
3636
api(projects.lexers)
37+
api(projects.pluginApi)
3738
api(projects.resources)
3839

3940
api(projects.shared)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This file is part of AndroidIDE.
3+
*
4+
* AndroidIDE is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* AndroidIDE is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.itsaky.androidide.syntax.decoration
19+
20+
import com.itsaky.androidide.plugins.extensions.EditorDecorationProvider
21+
22+
/**
23+
* Process-wide holder for the editor decoration providers contributed by enabled plugins.
24+
*
25+
* The app populates this from the plugin manager; the editor's tree-sitter span pipeline reads it
26+
* and merges each provider's spans into the rendered styling. It lives in `common` — a module the
27+
* editor pipeline already depends on — so the editor stays decoupled from the plugin manager while
28+
* still being plugin-driven. The IDE side is entirely feature-agnostic: it knows nothing about what
29+
* any provider decorates.
30+
*/
31+
object EditorDecorationRegistry {
32+
33+
@Volatile
34+
private var providers: List<EditorDecorationProvider> = emptyList()
35+
36+
/** Whether the editor is currently showing a dark theme; passed to each provider. */
37+
@Volatile
38+
@JvmField
39+
var isDark: Boolean = false
40+
41+
/** Replace the set of active decoration providers (empty disables decoration entirely). */
42+
@JvmStatic
43+
fun update(newProviders: List<EditorDecorationProvider>) {
44+
providers = newProviders
45+
}
46+
47+
/** The active decoration providers, or an empty list if none. */
48+
@JvmStatic
49+
fun providers(): List<EditorDecorationProvider> = providers
50+
}

editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/LineSpansGenerator.kt

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,14 @@ import io.github.rosemoe.sora.lang.styling.Span
5656
import io.github.rosemoe.sora.lang.styling.SpanFactory
5757
import io.github.rosemoe.sora.lang.styling.Spans
5858
import io.github.rosemoe.sora.lang.styling.TextStyle
59+
import io.github.rosemoe.sora.lang.styling.span.SpanConstColorResolver
60+
import io.github.rosemoe.sora.lang.styling.span.SpanExtAttrs
5961
import io.github.rosemoe.sora.text.CharPosition
6062
import io.github.rosemoe.sora.text.Content
6163
import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
64+
import com.itsaky.androidide.plugins.extensions.DecorationSpan
65+
import com.itsaky.androidide.syntax.decoration.EditorDecorationRegistry
66+
import java.util.TreeMap
6267
import kotlinx.coroutines.CoroutineScope
6368
import kotlinx.coroutines.SupervisorJob
6469
import kotlinx.coroutines.asCoroutineDispatcher
@@ -205,7 +210,88 @@ class LineSpansGenerator(internal var tree: TSTree, internal var lineCount: Int,
205210
if (list.isEmpty()) {
206211
list.add(emptySpan(0))
207212
}
208-
return list
213+
return applyDecorations(list, startIndex, endIndex)
214+
}
215+
216+
// ---------------------------------------------------------------------------
217+
// Plugin editor decorations
218+
//
219+
// After the base (tree-sitter) spans for a region are built, every registered
220+
// EditorDecorationProvider is given the region and returns additive, foreground-only color
221+
// spans, which are merged in here. The IDE is feature-agnostic — it knows nothing about what a
222+
// provider decorates (brackets, indent guides, markers, ...). Providers run on this analyze
223+
// thread and receive the full document content so they can use context outside the region.
224+
// ---------------------------------------------------------------------------
225+
226+
private fun applyDecorations(list: MutableList<Span>, startIndex: Int,
227+
endIndex: Int): MutableList<Span> {
228+
val providers = EditorDecorationRegistry.providers()
229+
if (providers.isEmpty() || endIndex <= startIndex) return list
230+
231+
val isDark = EditorDecorationRegistry.isDark
232+
var decorations: ArrayList<DecorationSpan>? = null
233+
for (provider in providers) {
234+
val spans = try {
235+
provider.decorate(content, startIndex, endIndex, isDark)
236+
} catch (t: Throwable) {
237+
Log.e(TAG, "Editor decoration provider failed", t)
238+
continue
239+
}
240+
if (spans.isEmpty()) continue
241+
(decorations ?: ArrayList<DecorationSpan>().also { decorations = it }).addAll(spans)
242+
}
243+
244+
val decos = decorations ?: return list
245+
return mergeDecorations(list, startIndex, endIndex, decos)
246+
}
247+
248+
/**
249+
* Merges additive, foreground-only decoration spans into [list]: overrides only the foreground
250+
* color of the covered characters while preserving every base style underneath, and keeps the
251+
* strictly-ascending, non-overlapping span ordering the renderer requires. Decoration offsets are
252+
* absolute; they are clipped to the region and converted to line-relative columns.
253+
*/
254+
private fun mergeDecorations(list: MutableList<Span>, startIndex: Int, endIndex: Int,
255+
decorations: List<DecorationSpan>): MutableList<Span> {
256+
val lineLen = endIndex - startIndex
257+
258+
// Snapshot of the base styles, used to restore the original style after each decorated range.
259+
val base = TreeMap<Int, Long>()
260+
for (s in list) base.putIfAbsent(s.column, s.style)
261+
val defaultStyle = TextStyle.makeStyle(EditorColorScheme.TEXT_NORMAL)
262+
fun baseStyleAt(col: Int): Long = base.floorEntry(col)?.value ?: defaultStyle
263+
264+
val result = TreeMap<Int, Span>()
265+
for (s in list) result.putIfAbsent(s.column, s)
266+
267+
for (d in decorations) {
268+
val s = (d.start - startIndex).coerceAtLeast(0)
269+
val e = (d.end - startIndex).coerceAtMost(lineLen)
270+
if (e <= s) continue
271+
272+
// Override the foreground at the range start and at every span boundary inside it, so the
273+
// color survives base-style changes within the range. Base style (bold/etc.) is preserved.
274+
var coveredStart = false
275+
for (col in result.subMap(s, true, e, false).keys.toList()) {
276+
result[col] = coloredSpan(col, result[col]!!.style, d.argb)
277+
if (col == s) coveredStart = true
278+
}
279+
if (!coveredStart) {
280+
result[s] = coloredSpan(s, baseStyleAt(s), d.argb)
281+
}
282+
// Resume the underlying style right after the range, unless a span already begins there.
283+
if (e < lineLen && !result.containsKey(e)) {
284+
result[e] = SpanFactory.obtain(e, baseStyleAt(e))
285+
}
286+
}
287+
288+
return ArrayList(result.values)
289+
}
290+
291+
private fun coloredSpan(column: Int, style: Long, argb: Int): Span {
292+
val span = SpanFactory.obtain(column, style)
293+
span.setSpanExt(SpanExtAttrs.EXT_COLOR_RESOLVER, SpanConstColorResolver(argb, 0))
294+
return span
209295
}
210296

211297
private fun createSpans(capture: TSQueryCapture, startColumn: Int, endColumn: Int,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
package com.itsaky.androidide.plugins.extensions
3+
4+
import com.itsaky.androidide.plugins.IPlugin
5+
6+
/**
7+
* Additive editor decoration extension: contributes foreground-color spans for regions of editor
8+
* text, layered on top of the normal syntax highlighting.
9+
*
10+
* The provider owns all of its own logic — it decides which characters to color and how. The IDE
11+
* is feature-agnostic: it simply calls [decorate] for each analyzed region and merges the returned
12+
* spans as foreground overrides. Because the only output is a list of colored ranges, a provider
13+
* can never replace or suppress the editor's existing highlighting.
14+
*
15+
* Typical uses: depth-colored brackets, indent guides, TODO/marker tinting, semantic emphasis.
16+
*/
17+
interface EditorDecorationProvider : IPlugin {
18+
19+
/**
20+
* Return additive, foreground-only color spans for the region `[start, end)` of [text].
21+
*
22+
* Called on a background analysis thread, once per analyzed region (typically a line), and may
23+
* be called frequently — keep it fast. [text] is the full, read-only document content so a
24+
* provider can use context outside the region (e.g. compute nesting depth by scanning the
25+
* prefix); only characters within `[start, end)` may be decorated.
26+
*
27+
* Returned [DecorationSpan]s use absolute character offsets into [text], must fall within
28+
* `[start, end)`, and should not overlap one another. Spans outside the region are ignored.
29+
*
30+
* @param text full document content (read-only).
31+
* @param start absolute character offset of the region start (inclusive).
32+
* @param end absolute character offset of the region end (exclusive).
33+
* @param isDark whether the editor is currently showing a dark theme.
34+
*/
35+
fun decorate(text: CharSequence, start: Int, end: Int, isDark: Boolean): List<DecorationSpan>
36+
}
37+
38+
/**
39+
* An additive foreground-color span. [start] and [end] are absolute character offsets into the
40+
* document ([end] exclusive); [argb] is the foreground color to apply, as a packed ARGB int.
41+
*/
42+
data class DecorationSpan(val start: Int, val end: Int, val argb: Int)

plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.itsaky.androidide.plugins.manager.services.CogoProjectProvider
1717
import com.itsaky.androidide.plugins.manager.services.IdeTooltipServiceImpl
1818
import com.itsaky.androidide.plugins.manager.services.IdeEditorTabServiceImpl
1919
import com.itsaky.androidide.plugins.extensions.DocumentationExtension
20+
import com.itsaky.androidide.plugins.extensions.EditorDecorationProvider
2021
import com.itsaky.androidide.plugins.extensions.FileOpenExtension
2122
import com.itsaky.androidide.plugins.extensions.SnippetExtension
2223
import com.itsaky.androidide.plugins.manager.services.IdeSnippetServiceImpl
@@ -794,6 +795,16 @@ class PluginManager private constructor(
794795
.filterIsInstance<FileOpenExtension>()
795796
}
796797

798+
/**
799+
* Get all enabled plugins that provide editor decorations (additive coloring of editor text).
800+
*/
801+
fun getEnabledEditorDecorationProviders(): List<EditorDecorationProvider> {
802+
return loadedPlugins.values
803+
.filter { it.isEnabled }
804+
.map { it.plugin }
805+
.filterIsInstance<EditorDecorationProvider>()
806+
}
807+
797808
fun notifyFileOpened(file: File) {
798809
getEnabledFileOpenExtensions().forEach { extension ->
799810
executeWithErrorHandling("notify file opened") {

0 commit comments

Comments
 (0)