Skip to content

Commit 0234c9a

Browse files
committed
Wire editor → plugin inline code-suggestion pipeline
Bridge editor content changes to plugin callbacks (EditorProviderImpl), render/accept ghost text (GhostTextRenderer + IDEEditor), and forward the content-change + inline-suggestion methods through delegatingEditorProvider instead of dropping them on interface no-ops.
1 parent e1c4ce9 commit 0234c9a

4 files changed

Lines changed: 228 additions & 2 deletions

File tree

‎app/src/main/java/com/itsaky/androidide/app/EditorProviderImpl.kt‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import com.itsaky.androidide.activities.editor.EditorHandlerActivity
77
import com.itsaky.androidide.models.Position
88
import com.itsaky.androidide.models.Range
99
import com.itsaky.androidide.models.SaveResult
10+
import com.itsaky.androidide.editor.ui.IDEEditor
11+
import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent
1012
import com.itsaky.androidide.plugins.manager.services.IdeEditorServiceImpl
1113
import com.itsaky.androidide.plugins.services.CursorPosition
1214
import com.itsaky.androidide.plugins.services.SelectionRange
1315
import io.github.rosemoe.sora.text.Content
1416
import io.github.rosemoe.sora.widget.CodeEditor
1517
import kotlinx.coroutines.launch
18+
import org.greenrobot.eventbus.EventBus
19+
import org.greenrobot.eventbus.Subscribe
20+
import org.greenrobot.eventbus.ThreadMode
1621
import org.slf4j.LoggerFactory
1722
import java.io.File
1823
import java.lang.ref.WeakReference
@@ -36,6 +41,8 @@ class EditorProviderImpl(
3641
private val activityRef = WeakReference(activity)
3742
private val mainHandler = Handler(Looper.getMainLooper())
3843
private val fileCallbacks = java.util.concurrent.CopyOnWriteArrayList<(File?) -> Unit>()
44+
private val contentCallbacks =
45+
java.util.concurrent.CopyOnWriteArrayList<(String, Int, Int, String) -> Unit>()
3946

4047
private val internalListener: (File?) -> Unit = { file ->
4148
fileCallbacks.forEach { cb ->
@@ -48,18 +55,47 @@ class EditorProviderImpl(
4855

4956
init {
5057
EditorEvents.addFileChangeListener(internalListener)
58+
// Content changes reach us via the editor's existing DocumentChangeEvent (posted to the
59+
// global EventBus on every edit); we fan them out to plugin-registered callbacks.
60+
EventBus.getDefault().register(this)
5161
}
5262

5363
/**
54-
* Detaches from EditorEvents and clears any plugin-registered callbacks. Called by
55-
* the activity in `onDestroy`.
64+
* Detaches from EditorEvents / EventBus and clears any plugin-registered callbacks. Called
65+
* by the activity in `onDestroy`.
5666
*/
5767
fun dispose() {
5868
EditorEvents.removeFileChangeListener(internalListener)
69+
EventBus.getDefault().unregister(this)
5970
fileCallbacks.clear()
71+
contentCallbacks.clear()
6072
activityRef.clear()
6173
}
6274

75+
/**
76+
* Bridges the editor's per-keystroke [DocumentChangeEvent] to the plugin content-change
77+
* contract `(fileContent, cursorLine, cursorColumn, language)`. Line/column are 0-indexed,
78+
* matching what plugins expect. Runs on the main thread so the editor cursor is current.
79+
*/
80+
@Subscribe(threadMode = ThreadMode.MAIN)
81+
fun onDocumentChange(event: DocumentChangeEvent) {
82+
if (contentCallbacks.isEmpty()) return
83+
val file = event.file.toFile()
84+
val editor = activity()?.getEditorForFile(file)?.editor
85+
val content = event.newText ?: editor?.text?.toString() ?: return
86+
// Prefer the live cursor; fall back to the change's end position (also 0-indexed).
87+
val cursor = editor?.cursor
88+
val line = cursor?.leftLine ?: event.changeRange.end.line
89+
val column = cursor?.leftColumn ?: event.changeRange.end.column
90+
val language = languageIdForFile(file) ?: file.extension.lowercase()
91+
contentCallbacks.forEach { cb ->
92+
try {
93+
cb(content, line, column, language)
94+
} catch (_: Exception) {
95+
}
96+
}
97+
}
98+
6399
private fun activity(): EditorHandlerActivity? = activityRef.get()?.takeIf { !it.isDestroyed }
64100

65101
// --- File state ---------------------------------------------------------
@@ -286,6 +322,28 @@ class EditorProviderImpl(
286322
fileCallbacks.remove(callback)
287323
}
288324

325+
override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {
326+
contentCallbacks.addIfAbsent(callback)
327+
}
328+
329+
override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {
330+
contentCallbacks.remove(callback)
331+
}
332+
333+
// --- Inline suggestions -------------------------------------------------
334+
335+
override fun showInlineSuggestion(text: String) {
336+
mainHandler.post {
337+
(inspectableEditor() as? IDEEditor)?.showInlineSuggestion(text)
338+
}
339+
}
340+
341+
override fun dismissInlineSuggestion() {
342+
mainHandler.post {
343+
(inspectableEditor() as? IDEEditor)?.dismissInlineSuggestion()
344+
}
345+
}
346+
289347
// --- Helpers ------------------------------------------------------------
290348

291349
private inline fun <T> Content.runEdit(block: Content.() -> T): T {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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.editor.ui
19+
20+
import android.graphics.Canvas
21+
import android.graphics.Paint
22+
import io.github.rosemoe.sora.widget.CodeEditor
23+
import io.github.rosemoe.sora.widget.EditorRenderer
24+
25+
/**
26+
* An [EditorRenderer] that draws a single-line inline "ghost text" suggestion (dimmed grey text)
27+
* at a fixed anchor position, after the editor's normal content. This backs the host side of the
28+
* inline-suggestion plugin pipeline (`IdeEditorService.showInlineSuggestion`).
29+
*
30+
* State is mutated from the main thread (via [IDEEditor]); [draw] reads it defensively and must
31+
* never throw — a bad suggestion or transient layout state can never take the editor's draw pass
32+
* down with it.
33+
*/
34+
class GhostTextRenderer(private val editor: CodeEditor) : EditorRenderer(editor) {
35+
36+
@Volatile
37+
private var suggestion: String? = null
38+
39+
@Volatile
40+
private var anchorLine: Int = -1
41+
42+
@Volatile
43+
private var anchorColumn: Int = -1
44+
45+
private val ghostPaint = Paint(Paint.ANTI_ALIAS_FLAG)
46+
47+
val hasSuggestion: Boolean
48+
get() = suggestion != null
49+
50+
/** Sets the pending suggestion anchored at the given 0-indexed [line]/[column]. */
51+
fun setSuggestion(text: String, line: Int, column: Int) {
52+
suggestion = text.takeIf { it.isNotEmpty() }
53+
anchorLine = line
54+
anchorColumn = column
55+
}
56+
57+
/** Clears any pending suggestion and returns the text that was showing (or null). */
58+
fun takeSuggestion(): String? {
59+
val current = suggestion
60+
clearSuggestion()
61+
return current
62+
}
63+
64+
fun clearSuggestion() {
65+
suggestion = null
66+
anchorLine = -1
67+
anchorColumn = -1
68+
}
69+
70+
override fun draw(canvas: Canvas) {
71+
super.draw(canvas)
72+
73+
val text = suggestion ?: return
74+
val line = anchorLine
75+
val column = anchorColumn
76+
try {
77+
val content = editor.text
78+
if (line < 0 || line >= content.lineCount) return
79+
if (column < 0 || column > content.getColumnCount(line)) return
80+
81+
val basePaint = editor.textPaint
82+
ghostPaint.textSize = basePaint.textSize
83+
ghostPaint.typeface = basePaint.typeface
84+
// Mid-grey at ~50% alpha reads as "ghost" on both light and dark themes.
85+
ghostPaint.color = GHOST_COLOR
86+
87+
val x = editor.getCharOffsetX(line, column)
88+
// getCharOffsetY returns the (screen) bottom of the row; sit the text on that baseline.
89+
val baseline = editor.getCharOffsetY(line, column) - ghostPaint.descent()
90+
// Ghost text is single-line: only draw up to the first newline.
91+
canvas.drawText(text.substringBefore('\n'), x, baseline, ghostPaint)
92+
} catch (_: Throwable) {
93+
// Never let suggestion rendering crash the editor.
94+
}
95+
}
96+
97+
private companion object {
98+
private const val GHOST_COLOR = 0x80888888.toInt()
99+
}
100+
}

‎editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import android.os.Bundle
2323
import android.os.Handler
2424
import android.os.Looper
2525
import android.util.AttributeSet
26+
import android.view.KeyEvent
2627
import android.view.MotionEvent
2728
import android.view.View
2829
import android.view.inputmethod.EditorInfo
@@ -88,6 +89,7 @@ import io.github.rosemoe.sora.event.SelectionChangeEvent
8889
import io.github.rosemoe.sora.lang.EmptyLanguage
8990
import io.github.rosemoe.sora.lang.Language
9091
import io.github.rosemoe.sora.widget.CodeEditor
92+
import io.github.rosemoe.sora.widget.EditorRenderer
9193
import io.github.rosemoe.sora.widget.EditorSearcher
9294
import io.github.rosemoe.sora.widget.IDEEditorSearcher
9395
import io.github.rosemoe.sora.widget.component.EditorAutoCompletion
@@ -891,6 +893,9 @@ constructor(
891893
}
892894

893895
markModified()
896+
// A pending inline suggestion is anchored to the pre-edit cursor position; any edit
897+
// invalidates it. The plugin re-issues one after its debounce.
898+
dismissInlineSuggestion()
894899
file ?: return@subscribeEvent
895900

896901
editorScope.launch {
@@ -905,6 +910,9 @@ constructor(
905910
return@subscribeEvent
906911
}
907912

913+
// Moving the cursor away from the anchor makes ghost text meaningless.
914+
dismissInlineSuggestion()
915+
908916
if (_diagnosticWindow?.isShowing == true) {
909917
_diagnosticWindow?.dismiss()
910918
}
@@ -918,6 +926,48 @@ constructor(
918926
EventBus.getDefault().register(this)
919927
}
920928

929+
// --- Inline suggestions (ghost text) ------------------------------------
930+
931+
override fun onCreateRenderer(): EditorRenderer = GhostTextRenderer(this)
932+
933+
private val ghostRenderer: GhostTextRenderer?
934+
get() = renderer as? GhostTextRenderer
935+
936+
/**
937+
* Shows [text] as dimmed inline ghost text anchored at the current cursor position. Called by
938+
* the host editor provider when a plugin returns an inline completion. No-op if the editor is
939+
* released or has no cursor.
940+
*/
941+
fun showInlineSuggestion(text: String) {
942+
if (isReleased || text.isEmpty()) return
943+
val renderer = ghostRenderer ?: return
944+
val cursor = cursor ?: return
945+
renderer.setSuggestion(text, cursor.leftLine, cursor.leftColumn)
946+
invalidate()
947+
}
948+
949+
/** Removes any showing ghost text. */
950+
fun dismissInlineSuggestion() {
951+
val renderer = ghostRenderer ?: return
952+
if (renderer.hasSuggestion) {
953+
renderer.clearSuggestion()
954+
invalidate()
955+
}
956+
}
957+
958+
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
959+
// Accept a showing suggestion on Tab: commit it at the cursor and consume the key.
960+
if (keyCode == KeyEvent.KEYCODE_TAB && ghostRenderer?.hasSuggestion == true) {
961+
val suggestion = ghostRenderer?.takeSuggestion()
962+
invalidate()
963+
if (!suggestion.isNullOrEmpty()) {
964+
commitText(suggestion)
965+
return true
966+
}
967+
}
968+
return super.onKeyDown(keyCode, event)
969+
}
970+
921971
private fun handleCustomTextReplacement(event: ContentChangeEvent) {
922972
val isEnterPress =
923973
event.action == ContentChangeEvent.ACTION_INSERT &&

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ class PluginManager private constructor(
113113
* silently drop on the floor during the early-boot window.
114114
*/
115115
private val pendingFileChangeCallbacks = java.util.concurrent.CopyOnWriteArraySet<(File?) -> Unit>()
116+
private val pendingContentChangeCallbacks =
117+
java.util.concurrent.CopyOnWriteArraySet<(String, Int, Int, String) -> Unit>()
116118

117119
private val delegatingEditorProvider = object : IdeEditorServiceImpl.EditorProvider {
118120
private fun current(): IdeEditorServiceImpl.EditorProvider? = editorProvider
@@ -161,6 +163,16 @@ class PluginManager private constructor(
161163
pendingFileChangeCallbacks.remove(callback)
162164
current()?.removeFileChangeCallback(callback)
163165
}
166+
override fun addContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {
167+
pendingContentChangeCallbacks.add(callback)
168+
current()?.addContentChangeCallback(callback)
169+
}
170+
override fun removeContentChangeCallback(callback: (String, Int, Int, String) -> Unit) {
171+
pendingContentChangeCallbacks.remove(callback)
172+
current()?.removeContentChangeCallback(callback)
173+
}
174+
override fun showInlineSuggestion(text: String) { current()?.showInlineSuggestion(text) }
175+
override fun dismissInlineSuggestion() { current()?.dismissInlineSuggestion() }
164176
}
165177

166178
// Configurable permissions for different services
@@ -998,12 +1010,18 @@ class PluginManager private constructor(
9981010
pendingFileChangeCallbacks.forEach { cb ->
9991011
runCatching { previous.removeFileChangeCallback(cb) }
10001012
}
1013+
pendingContentChangeCallbacks.forEach { cb ->
1014+
runCatching { previous.removeContentChangeCallback(cb) }
1015+
}
10011016
}
10021017
this.editorProvider = provider
10031018
if (provider != null) {
10041019
pendingFileChangeCallbacks.forEach { cb ->
10051020
runCatching { provider.addFileChangeCallback(cb) }
10061021
}
1022+
pendingContentChangeCallbacks.forEach { cb ->
1023+
runCatching { provider.addContentChangeCallback(cb) }
1024+
}
10071025
}
10081026
}
10091027

0 commit comments

Comments
 (0)