: this is langlemangler generated prose, sorry; i'm gonna rewrite it ASAP
The Compose =BasicTextField=-based OrgBodyEditor re-lays-out the entire document on every keystroke and re-runs seven whole-document regex passes inside OrgVisualTransformation, so long documents drag the UI thread and eventually ANR (body-editor.org). This module replaces the editing surface with sora-editor (io.github.rosemoe:editor + language-textmate), which virtualizes text layout (only visible lines are laid out) and runs TextMate highlighting incrementally in the background. Editing happens in a sora CodeEditor mounted via Compose AndroidView; everything around it (properties splicing, save, attachments) stays in editor/viewmodel.org and editor/screen.org untouched.
The syntax highlighting grammar is the org TextMate grammar from vscode-org-mode, vendored and patched. That repository is GPL-3.0-or-later; the vendored files therefore make this app redistributable only under GPL/AGPL terms. That is acceptable: this project's redistribution is under AGPL terms. Deriving a grammar from Emacs font-lock instead would not dodge that — it would be a derivative of a GPL work all the same.
Vendored Assets
The grammar and language configuration are vendored into the APK assets, not tangled from source blocks. This is the same treatment the web module gives static/ assets: binary-ish blobs that are copied in and documented by provenance. Edit them with a JSON tool if the grammar needs further patching.
| Asset | Source | Patches applied here |
|---|---|---|
app/src/main/assets/textmate/org/syntaxes/org.tmLanguage.json | vscode-org-mode syntaxes/org.tmLanguage.json (GPL-3.0-or-later) | active (angle) timestamps; verbatim/code distinct scopes; ALLCAPS user-keyword pattern narrowed; :PROPERTIES: drawer rule |
app/src/main/assets/textmate/org/language-configuration.json | vscode-org-mode org.configuration.json | none |
app/src/main/assets/textmate/themes/org-dark.json / org-light.json | written from scratch | — |
The scope names emitted by the grammar (and mapped to colors by the themes) are worth having in one place:
| Grammar scope | Meaning | Dark color |
|---|---|---|
markup.heading.org, entity.name.*.org | heading lines (all levels) | #5588BB |
meta.link.inline.org | ~...~ links | #6699CC |
string.other.verbatim.org | =verbatim= | #8FAA54 |
string.other.code.org | ~~code~~ | #CC8844 |
variable.org(.-active-timestamp)? | [...] / <...> timestamps | #9988CC |
support.type.property-name.org | #+KEYWORD: lines | #888888 |
keyword.control.block.org | #+BEGIN_/END_ lines | #888888 |
invalid.illegal.org | TODO | #CC6666 |
keyword.control.org | DONE | #8FAA54 |
string.quoted.double.org | user TODO keywords | #CC8844 |
comment.line | # comment | #707070 |
The upstream grammar had three defects we patched rather than worked around:
Its
timestamprule only matches bracket timestamps ~~; org's planning lines use angle timestamps ~~ which are the ones this app writes viaPlanningInfoUtils. Added avariable.org.active-timestamppattern.Both
=verbatim=and ~~code~~ shared the single scopevariable.org— the themes in body-editor.org color them differently (#8FAA54 vs #CC8844). Split intostring.other.verbatim.org/string.other.code.org.Its
userKeywordsrule colors any 3+ letter ALL-CAPS word, which lights up acronyms throughout normal prose. Narrowed it to a fixed set of TODO-keyword-shaped states matching the app's defaulttodoStates; a word likeGPSstops getting colored.Link completion commits an UNCLOSED link — a shameful hack, and the author hates it. sora honors the
autoClosingPairselectric pairs, so typing[ [yields ~^~ (caret between the auto-inserted]]). The "clever" resolution: link completion items commit ~<prefix>Title~ *without the trailing ~~*, and the leftover electric pair's]]right of the caret completes it into a full link.prefixLengthconsumes the typed query + the ~ the correctness of an insertion now depends on a side effect of the symbol-pair engine that nobody asked to participate in this transaction. It breaks the moment anything else touches the text between trigger and commit — delete type one character past the auto-inserted ~~, turn offsymbolPairAutoCompletionin settings, or commit into a selection (performCompletionreplaces backward from the caret; a stray]]on the right is then orphaned). The dependency is invisible in the code — nothing at the commit site mentions the pairing — and the only documentation is this paragraph. The clean fix is sora exposing an ~excludePairs~-style scope condition onautoClosingPairs(VS Code hasnotIn, tm4e parses it, but sora'sSymbolPairExhookup only consults token types, not scopes) — upstream-worthy, not hack-around-able from an app embedding the library. If this ever breaks, the failure mode is "every accepted link grows a spurious ~]]~"; look here first rather than re-blaming the commit logic.
OrgTextMateSetup — one-shot registry loading
Loads the FileProvider, both themes, and the org grammar into sora's global registries. Idempotent: called from the composable factory on first editor creation. Follows the standard language-textmate flow.
design: why a singleton with once-guard
FileProviderRegistry, ThemeRegistry, GrammarRegistry are process-global singletons in sora; loading twice would either throw or double-register themes. A @Volatile once flag is enough — there is exactly one editor alive at a time in the app's single EditingView.
computer.whatthefuck.arcology.app.ui.components.sora.OrgTextMateSetup
package computer.whatthefuck.arcology.app.ui.components.sora
import android.content.Context
import android.util.Log
import io.github.rosemoe.sora.langs.textmate.registry.FileProviderRegistry
import io.github.rosemoe.sora.langs.textmate.registry.GrammarRegistry
import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import io.github.rosemoe.sora.langs.textmate.registry.dsl.languages
import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
import io.github.rosemoe.sora.langs.textmate.registry.provider.AssetsFileResolver
import org.eclipse.tm4e.core.registry.IThemeSource
import org.json.JSONArray
import org.json.JSONObject
/**
* One-shot loading of the org TextMate grammar + language configuration into
* sora's process-global registries, plus theme management.
*
* Two theme sources coexist:
* 1. Static asset themes (org-dark.json / org-light.json) as fallbacks —
* loaded once at init.
* 2. Runtime-generated themes built from the Material3 color scheme in
* [applyMaterialTheme] — preferred; the editor tracks the app's
* dynamic-color (or static Material) palette, not a hardcoded one.
*/
object OrgTextMateSetup {
private const val TAG = "OrgTextMateSetup"
@Volatile
private var initialized = false
const val ORG_SCOPE_NAME = "source.org"
const val DARK_THEME_NAME = "org-dark"
const val LIGHT_THEME_NAME = "org-light"
const val MATERIAL_THEME_PREFIX = "org-material-"
@Synchronized
fun ensureInitialized(context: Context) {
if (initialized) return
val assets = context.applicationContext.assets
FileProviderRegistry.getInstance().addFileProvider(AssetsFileResolver(assets))
val themeRegistry = ThemeRegistry.getInstance()
themeRegistry.loadTheme(
ThemeModel(
IThemeSource.fromInputStream(
FileProviderRegistry.getInstance().tryGetInputStream("textmate/themes/org-dark.json"),
"textmate/themes/org-dark.json",
null
),
DARK_THEME_NAME
).apply { isDark = true }
)
themeRegistry.loadTheme(
ThemeModel(
IThemeSource.fromInputStream(
FileProviderRegistry.getInstance().tryGetInputStream("textmate/themes/org-light.json"),
"textmate/themes/org-light.json",
null
),
LIGHT_THEME_NAME
)
)
themeRegistry.setTheme(DARK_THEME_NAME)
GrammarRegistry.getInstance().loadGrammars(
languages {
language("org") {
grammar = "textmate/org/syntaxes/org.tmLanguage.json"
scopeName = ORG_SCOPE_NAME
languageConfiguration = "textmate/org/language-configuration.json"
}
}
)
initialized = true
}
/**
* Build a TextMate theme JSON string from Material3 palette values. The
* syntax colors (headings/links/verbatim/...) keep the org palette
* documented in the scope table; only the *neutral* roles (background,
* default text, selection, line highlight, completion window) come from
* MaterialTheme so the editor blends with the app chrome.
*/
private fun manageAlpha(color: Int, alpha: Int): Int = (color and 0x00FFFFFF) or (alpha shl 24)
fun materialThemeJson(
dark: Boolean,
themeName: String,
surface: Int,
onSurface: Int,
primary: Int,
surfaceVariant: Int
): String {
val root = JSONObject()
val scopeColors = orgScopeColors(dark)
// tm4e's ColorUtils.parseRGBAToARGB reads 8-digit hex as #RRGGBBAA
// (VSCode convention) — NOT Android's #AARRGGBB. Emitting Android-style
// ARGB byte-rotates every color: #FFF7F9FD became 0xFDFFF7F9 =
// pinkish "rose" background, #FF2C3339 became 0x39FF2C33 = translucent
// red text. Hex output is therefore RRGGBBAA with the alpha last.
fun hex(c: Int): String {
val r = (c ushr 16) and 0xFF
val g = (c ushr 8) and 0xFF
val b = c and 0xFF
val a = (c ushr 24) and 0xFF
return String.format("#%02X%02X%02X%02X", r, g, b, a)
}
val globals = JSONObject()
globals.put("background", hex(surface))
globals.put("foreground", hex(onSurface))
globals.put("caret", hex(primary))
globals.put("selection", hex(surfaceVariant))
globals.put("lineHighlight", hex(surfaceVariant))
globals.put("invisibles", hex(manageAlpha(surface, 0x40)))
globals.put("completionWindowBackground", hex(surface))
globals.put("highlightedDelimitersForeground", hex(primary))
root.put("name", themeName)
root.put("type", if (dark) "dark" else "light")
val settings = JSONArray()
settings.put(JSONObject().put("settings", globals))
for ((scope, color) in scopeColors) {
settings.put(
JSONObject()
.put("scope", scope)
.put("settings", JSONObject().put("foreground", hex(color)))
)
}
root.put("settings", settings)
return root.toString()
}
/**
* Generate and activate a theme from the current Material3 scheme values.
* Called on every dark/light flip: generates the JSON, registers it under
* a stable name, and sets it current. The TextMateColorScheme and
* TextMateAnalyzer both listen to ThemeRegistry changes, so the editor and
* highlighting re-color live.
*/
private var generation = 0
@Synchronized
fun applyMaterialTheme(
dark: Boolean,
surface: Int,
onSurface: Int,
primary: Int,
surfaceVariant: Int
) {
ensureInitializedNameOnly(dark)
// ThemeRegistry.loadTheme dedupes by the theme NAME it finds in the JSON
// (findThemeByThemeName compares against rawTheme.getName()): a stable
// name makes every regeneration resolve to the FIRST generated model
// and the fresh JSON is discarded — the scheme then never updates. The
// name therefore carries a generation counter so each call registers a
// new model and switches to it.
generation += 1
val name = MATERIAL_THEME_PREFIX + (if (dark) "dark" else "light") + "-" + generation
val json = materialThemeJson(dark, name, surface, onSurface, primary, surfaceVariant)
val registry = ThemeRegistry.getInstance()
try {
val source = IThemeSource.fromInputStream(
java.io.ByteArrayInputStream(json.toByteArray()),
"material-theme-$generation.json",
java.nio.charset.StandardCharsets.UTF_8
)
val model = ThemeModel(source, name).apply { isDark = dark }
registry.loadTheme(model, true)
Log.i(
TAG,
"applied material theme $name surface=" + java.lang.String.format("#%08X", surface) +
" onSurface=" + java.lang.String.format("#%08X", onSurface) +
" current=" + registry.currentThemeModel.name
)
} catch (e: Exception) {
Log.w(TAG, "material theme generation failed; falling back to static", e)
selectTheme(dark)
}
}
/**
* The static asset themes are only used as fallback if material generation
* fails; ensure they are registered even when applyMaterialTheme is the
* first caller (ensureInitialized is a superset but requires an Activity
* context at times when only application context exists).
*/
private fun ensureInitializedNameOnly(dark: Boolean) {
// no-op today: ensureInitialized is always called first from the
// composable with an Activity context. Kept as a seam for safety.
}
fun selectTheme(dark: Boolean) {
ThemeRegistry.getInstance().setTheme(if (dark) DARK_THEME_NAME else LIGHT_THEME_NAME)
}
/**
* The org syntax palette (documented in the scope table above). Neutral
* roles are overridden from MaterialTheme; these stay fixed so the
* semantic highlighting remains recognizable across dark/light.
*/
private fun orgScopeColors(dark: Boolean): List<Pair<String, Int>> {
return if (dark) listOf(
"markup.heading.org,entity.name.type.org,entity.name.function.org,entity.other.attribute-name.org" to 0xFF5588BB.toInt(),
"meta.link.inline.org,markup.underline.link.org,string.other.link.title.org" to 0xFF6699CC.toInt(),
"string.other.verbatim.org" to 0xFF8FAA54.toInt(),
"string.other.code.org" to 0xFFCC8844.toInt(),
"variable.org,variable.org.active-timestamp" to 0xFF9988CC.toInt(),
"support.type.property-name.org" to 0xFF888888.toInt(),
"keyword.control.block.org" to 0xFF888888.toInt(),
"invalid.illegal.org" to 0xFFCC6666.toInt(),
"keyword.control.org" to 0xFF8FAA54.toInt(),
"string.quoted.double.org" to 0xFFCC8844.toInt(),
"comment.line,punctuation.definition.comment" to 0xFF707070.toInt(),
"meta.drawer.properties.org,keyword.control.drawer.properties.org,keyword.control.drawer.end.org" to 0xFF888888.toInt()
) else listOf(
"markup.heading.org,entity.name.type.org,entity.name.function.org,entity.other.attribute-name.org" to 0xFF4A78A5.toInt(),
"meta.link.inline.org,markup.underline.link.org,string.other.link.title.org" to 0xFF336699.toInt(),
"string.other.verbatim.org" to 0xFF660000.toInt(),
"string.other.code.org" to 0xFF904422.toInt(),
"variable.org,variable.org.active-timestamp" to 0xFF7755AA.toInt(),
"support.type.property-name.org" to 0xFF888888.toInt(),
"keyword.control.block.org" to 0xFF888888.toInt(),
"invalid.illegal.org" to 0xFFAA0000.toInt(),
"keyword.control.org" to 0xFF660000.toInt(),
"string.quoted.double.org" to 0xFF904422.toInt(),
"comment.line,punctuation.definition.comment" to 0xFF999999.toInt(),
"meta.drawer.properties.org,keyword.control.drawer.properties.org,keyword.control.drawer.end.org" to 0xFF888888.toInt()
)
}
}OrgLinkQuery — pure logic for trigger detection
The trigger logic lives in pure functions so the Compose-free parts can be JVM-tested: extracting the pending link-query before the cursor, deciding whether a < was just typed outside a link/timestamp, and computing the list-continuation prefix for the line above the cursor. These are ports of the logic in body-editor.org's detectLinkTrigger detectTimestampTrigger handleListContinuation, restructured to operate on line strings instead of the whole text.
design: windows, not whole-text scans
The old implementations called lastIndexOf with the two-link-bracket prefix back to position 0 — O(cursor) per keystroke with a substring allocation. Here the text before the cursor is bounded to a 1KB tail window; double-bracket links are short and 1KB is far more than any realistic partial title. The newline-continuation logic gets the previous line only.
computer.whatthefuck.arcology.app.ui.components.sora.OrgLinkQuery
package computer.whatthefuck.arcology.app.ui.components.sora
/**
* Pure (JVM-testable) trigger detection for the org editor.
* Inputs are line strings / bounded text windows, never a whole document reference.
*/
object OrgLinkQuery {
/** Tail window used when looking for an unclosed double-bracket before the cursor. */
const val TRIGGER_WINDOW = 1024
/**
* If the cursor sits inside an unclosed ~[[query]]~, return the partial query text
* typed since the last double-open-bracket. Returns null when not in link-completion context.
*/
fun pendingLinkQuery(beforeCursor: String): String? {
val window = beforeCursor.takeLast(TRIGGER_WINDOW)
val lastOpen = window.lastIndexOf("[[")
if (lastOpen == -1) return null
val between = window.substring(lastOpen + 2)
if (between.contains("]]") || between.contains("][")) return null
return between
}
/**
* Whether the just-typed character ~c~ (at ~cursor-1~) should open the timestamp
* picker: it must be a lone ~<~, not inside an open link, and not already inside
* an unterminated ~<...~ timestamp.
*/
fun shouldTriggerTimestamp(
justInserted: Char,
beforeInserted: String
): Boolean {
if (justInserted != '<') return false
// beforeInserted includes the just-typed '<' as its last character;
// drop it so the unterminated-timestamp check examines prior text only.
val window = beforeInserted.dropLast(1).takeLast(TRIGGER_WINDOW)
val lastOpen = window.lastIndexOf("[[")
if (lastOpen != -1) {
val between = window.substring(lastOpen + 2)
if (!between.contains("]]") && !between.contains("][")) return false
}
val lastClose = window.lastIndexOf('>')
val lastOpenTs = window.lastIndexOf('<')
return lastOpenTs <= lastClose
}
/**
* Given the line *before* the newly inserted newline, compute the list
* continuation: null means "no continuation, plain newline", empty string
* means "the empty bullet line should be removed", otherwise the prefix to
* insert (e.g. ~- ~ with inherited indentation).
*/
fun listContinuation(previousLine: String): String? {
val trimmed = previousLine.trim()
if (trimmed == "-") return ""
val match = Regex("""^(\s*)-\s""").find(previousLine) ?: return null
return "${match.groupValues[1]}- "
}
}OrgSoraLanguage — the Language implementation
Subclasses TextMateLanguage (constructor is protected upstream, which is the sanctioned extension path). Highlights come from the grammar; this class adds the org-specific assists:
requireAutoComplete: inside an unclosed link bracket → FTS node suggestions committed as full links; just after a lone<→ formatted timestamp suggestions and a "Pick date…" item that asks the Compose layer to open the picker dialog.getNewlineHandlers: list-line continuation.
Also carries timestampPickerRequested (a shared MutableStateFlow<Boolean>) so pressing "Pick date…" in the completion window surfaces as a Compose-side event. The flow is observed by OrgBodyEditorSora.
design: runBlocking in the completion thread
requireAutoComplete is called on sora's completion worker thread (see EditorAutoCompletion in the library), with interruption flags in ContentReference for cancellation. A blocking repository call there is correct and does not touch the UI thread. CompletionPublisher may be called incrementally; we add all items at once.
design: completion commit mechanics
SimpleCompletionItem(label, desc, prefixLength, commitText): prefixLength is how much of the typed prefix gets replaced by commitText — SimpleCompletionItem.performCompletion runs text.replace(line, column - prefixLength, line, column, commitText). For links the replaced range must cover the typed query plus the opening double-bracket pair (query.length + 2), otherwise the brackets survive and the commit doubles them: an extra unmatched pair appears before the committed link. The timestamp items commit with prefixLength = 1 (replacing only the lone <); Pick date… overrides performCompletion to instead arm timestampPickerRequested. Link items additionally commit unclosed — the commit text ends after the description segment, no closing brackets — so the electric pair's auto-inserted ]] to the caret's right completes the link; a shameful hack whose failure modes are catalogued in the vendored-assets patch list; the author hates it.
computer.whatthefuck.arcology.app.ui.components.sora.OrgSoraLanguage
package computer.whatthefuck.arcology.app.ui.components.sora
import android.os.Bundle
import android.util.Log
import io.github.rosemoe.sora.lang.completion.CompletionItem
import io.github.rosemoe.sora.lang.completion.CompletionPublisher
import io.github.rosemoe.sora.lang.completion.SimpleCompletionItem
import io.github.rosemoe.sora.lang.smartEnter.NewlineHandleResult
import io.github.rosemoe.sora.lang.smartEnter.NewlineHandler
import io.github.rosemoe.sora.lang.styling.Styles
import io.github.rosemoe.sora.langs.textmate.TextMateLanguage
import io.github.rosemoe.sora.langs.textmate.registry.GrammarRegistry
import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import io.github.rosemoe.sora.text.CharPosition
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.ContentReference
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/**
* Org mode TextMate language: grammar highlighting plus org assists.
* One instance serves one editor (sora constraint).
*/
@OptIn(ExperimentalTime::class)
class OrgSoraLanguage(
private val searchService: SearchService?
) : TextMateLanguage(
GrammarRegistry.getInstance().findGrammar(OrgTextMateSetup.ORG_SCOPE_NAME),
GrammarRegistry.getInstance().findLanguageConfiguration(OrgTextMateSetup.ORG_SCOPE_NAME),
GrammarRegistry.getInstance(),
ThemeRegistry.getInstance(),
false
) {
/** Compose opens the timestamp picker dialog when this goes true. */
val timestampPickerRequested = MutableStateFlow(false)
/** Pre-commit line number whose newline commit should trigger empty-bullet cleanup. */
val pendingBulletCancelLine = MutableStateFlow<Int?>(null)
override fun requireAutoComplete(
content: ContentReference,
position: CharPosition,
publisher: CompletionPublisher,
extraArguments: Bundle
) {
val index = position.index
val before = boundedBeforeText(content, position.line, position.column)
val query = OrgLinkQuery.pendingLinkQuery(before)
if (query != null) {
publishLinkCompletions(query, publisher)
return
}
val justTyped = if (position.column > 0 || position.line > 0) {
before.lastOrNull() ?: ' '
} else ' '
if (justTyped == '<' && OrgLinkQuery.shouldTriggerTimestamp(justTyped, before)) {
publishTimestampCompletions(publisher)
}
}
/**
* At most [OrgLinkQuery.TRIGGER_WINDOW] chars of text immediately before
* (line, column), assembled with ~ContentReference.getLine~ (the idiom the
* library itself uses in CompletionHelper; avoids the three-overload charAt
* ambiguity in Kotlin).
*/
private fun boundedBeforeText(content: ContentReference, line: Int, column: Int): String {
val sb = StringBuilder()
var budget = OrgLinkQuery.TRIGGER_WINDOW
var lineIdx = line
var colIdx = column
while (budget > 0 && lineIdx >= 0) {
val lineText = try {
content.getLine(lineIdx)
} catch (e: Exception) {
break
}
val takeFrom = if (lineIdx == line) colIdx else lineText.length
val take = minOf(budget, takeFrom)
if (take > 0) {
sb.insert(0, lineText.substring(takeFrom - take, takeFrom))
}
budget -= take
lineIdx--
if (budget > 0 && lineIdx >= 0) sb.insert(0, '\n')
}
return sb.toString()
}
private fun publishLinkCompletions(rawQuery: String, publisher: CompletionPublisher) {
val service = searchService ?: return
val query = rawQuery.trim()
if (query.isEmpty()) return
val results: List<SearchResult> = try {
runBlocking { service.searchPrimary(query, 10) }
} catch (e: Exception) {
Log.w(TAG, "link completion search failed", e)
emptyList()
}
// The language configuration's electric pair inserts ]] when [ is typed:
// after " See [[query" the buffer holds " See [[query]]" (cursor inside).
// The commit must therefore consume [[ + the query (prefixLength =
// query.length + 2 — SimpleCompletionItem.performCompletion replaces
// [column - prefixLength, column)) and insert the link WITHOUT its
// trailing ]], because the auto-closed ]] sits right of the cursor and
// completes it: "[[id:x][Title" + "]]" == a full link. Committing a
// closed link doubled every bracket pair in earlier builds.
val commitPrefixLength = rawQuery.length + 2
for (result in results) {
val node = result.node
val title = node.title ?: "Untitled"
publisher.addItem(
SimpleCompletionItem(
title,
node.file,
commitPrefixLength,
"[[id:${node.id}][$title"
)
)
}
}
private fun publishTimestampCompletions(publisher: CompletionPublisher) {
val now: LocalDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val today: LocalDate = now.date
val withTime = PlanningInfoUtils.formatTimestamp(
today, now.time, isActive = true, repeater = null, showWeekday = true
)
val noTime = PlanningInfoUtils.formatTimestamp(
today, null, isActive = true, repeater = null, showWeekday = true
)
publisher.addItem(SimpleCompletionItem(withTime, "insert active timestamp with time", 1, withTime))
publisher.addItem(SimpleCompletionItem(noTime, "insert active timestamp, no time", 1, noTime))
publisher.addItem(PickDateCompletionItem(this))
}
override fun getNewlineHandlers(): Array<NewlineHandler> = arrayOf(OrgListContinuationHandler())
/**
* Continue list items on Enter. Enter on an empty bullet arms
* [pendingBulletCancelLine] (the deletion itself happens post-commit in
* OrgBodyEditorSora's content-change handler, because sora's newline path
* cannot delete).
*/
private inner class OrgListContinuationHandler : NewlineHandler {
override fun matchesRequirement(
text: Content,
position: CharPosition,
style: Styles?
): Boolean = true
override fun handleNewline(
text: Content,
position: CharPosition,
style: Styles?,
tabSize: Int
): NewlineHandleResult {
val previousLine = if (position.line > 0) text.getLineString(position.line - 1) else ""
return when (val prefix = OrgLinkQuery.listContinuation(previousLine)) {
null -> NewlineHandleResult("\n", 0)
"" -> {
pendingBulletCancelLine.value = position.line - 1
NewlineHandleResult("\n", 0)
}
else -> NewlineHandleResult("\n$prefix", 0)
}
}
}
internal class PickDateCompletionItem(
private val language: OrgSoraLanguage
) : SimpleCompletionItem("Pick date\u2026", "open the date picker dialog", 1, "<") {
override fun performCompletion(editor: io.github.rosemoe.sora.widget.CodeEditor, text: Content, line: Int, column: Int) {
language.timestampPickerRequested.value = true
}
}
companion object {
private const val TAG = "OrgSoraLanguage"
}
}Known tradeoff: empty-bullet cancel
sora's newline path can only insert text and shift the cursor left; it cannot delete. The old editor's "Enter on an empty bullet removes it" trick becomes a two-step protocol:
The newline handler returns a plain newline but arms
pendingBulletCancelLinewith the pre-commit line index of the empty bullet (it has exactly that information, and it is stable:handleNewlinereceives the pre-commitContentand cursor).=OrgBodyEditorSora='s
ContentChangeEventhandler — which fires after the editor committed the insertion — reads the hint and, if the armed line is a bare-(optionally indented), deletes that line, then clears the hint.
The state flows through the language because the handler inner class lives there; the bridge observes it.
Known tradeoff: suppressEcho can leak the pending bullet-cancel
The two-phase cancel above carries a subtler failure mode, found via a
production crash (note 20261019T000055.844183, ~java.lang.StringIndexOutOfBoundsException: Index 644
out of bounds. length:615~ fired from the Q action's onClick): the handler's
suppressEcho early-return ran before the hint check, so an arm whose next
ContentChangeEvent happened to be a suppressed echo was not processed — the
hint survived into an unrelated later mutation. If that mutation is a region
edit with pre-captured (line, column) coordinates and an open batch edit (the
format actions in OrgTextActionWindow), the deferred line delete fires between the
batch's delete and insert dispatches; the edit's coordinates, computed from
the pre-edit document, no longer describe the in-flight document. The old
wrap code then queried the caret as ~sel.right + prefix.length +
selected.length + suffix.length~ against the shrunk content — sora's
CachedIndexer.getCharPosition runs Content.checkIndex first and throws
StringIndexOutOfBoundsException (644 requested vs. 615 actually present).
The stale CachedIndexer cache suggested by the note title can misplace a
caret, but it cannot throw: the bounds check is on the requested index
against the real length, before any cache is consulted.
Two coordinated hardenings (2026-10-19):
The handler now processes the armed hint first and regardless of suppression, and when a foreign batch edit is open it defers (keeps the hint armed) instead of deleting mid-dispatch — an edit in flight must not have its coordinates shifted.
The caret after any region edit is computed by ~OrgTextActionMath~'s local (line, column) arithmetic; no post-edit char index is ever derived from the pre-edit document, so no listener mutation elsewhere can push the caret query out of bounds.
OrgSoraEditorState.updateandapplyEditadditionally reject stale char ranges with a log instead of crashing.
The residual cost of the isInBatchEdit defer: if every subsequent content
event for a while happens inside a foreign batch, the empty bullet lingers
until some non-batched event arrives. The delete still requires the armed
line to be a bare - at processing time, so a stale hint cannot remove real
content; the worst case is a bullet that outlives the user's Enter.
OrgBodyEditorSora — the Compose bridge
AndroidView wrapping a CodeEditor. Text lives in the editor (sora's own Content); the Compose layer pulls it out (debounced ContentChangeEvent → onValueChange(TextFieldValue)) and pushes splices in (update(value) keyed by externalChangeKey — metadata rebuild, refile reload, template application).
design: sync direction flags and the echo-loop
Two-way binding between Compose state and a View risks feedback loops: editor event updates state, state change updates editor, editor fires again. Two guards cooperate:
suppressEcho(bridge-internal): set while the bridge is pushing changes into the editor, cleared by the echoContentChangeEvent(ACTION_SET_NEW_TEXT) that everysetTextdispatches.lastExportedViaEditor(screen-level): texts the editor exported via the debounced flow are remembered; the screen'sLaunchedEffect(fullContent)only bumpsexternalChangeKeyStatewhen the VM-side text differs from BOTH mirrors — i.e. a true external splice. Getting this wrong is expensive and non-obvious: a spurious bump meanssetTextmid-typing, which (a) cancels the in-flight completion window unconditionally (ACTION_SET_NEW_TEXT → hide()in EditorAutoCompletion), (b) resets the IME composing region (autocarrot mid-word edits), and (c) flashes the completion popup. The export path therefore only marks (externalChangeBumper), and paths that genuinely bypass the editor Content (link-picker insert, timestamp insert) clear the marker (externalChangeBumper(null)) so the LaunchedEffect bump pushes their result into the editor.
Additionally OrgSoraEditorState.update full-NOOPs when text + selection already agree, protecting against any residual bump echoes without disturbing the IME.
design: why TextFieldValue still crosses the boundary
OrgDocumentEditorViewModel splices text as TextFieldValue(text, selection) (rebuildHeadingLine, rebuildPropertiesDrawer, updatePlanning, …) and the save pipeline extracts the body from it. Keeping TextFieldValue as the crossing type means zero ViewModel changes. Coordinates agree: sora's CharPosition.index is an absolute char index into Content, and Content normalizes line separators to \n — the same coordinate space as the org files this app reads (LF).
design: selection → floating toolbar
The floating wrap-toolbar in EditingView is fed from the bridge's selectionChanged flow (SelectionChangeEvent → TextRange) and applies its edits through state.applyEdit(start, end, newText) / wrapSelection(prefix, suffix), which are ~beginBatchEdit~-wrapped region operations on Content.
design: push-in vs pull-out cadence
User typing → immediate selection echo, debounced (250ms) content export — the export calls the ViewModel which splices state back? No: the ViewModel's updateFullContent is a plain state setter, so the export stops there. Splices that arrive for non-typing reasons (setters calling rebuildPropertiesDrawer etc.) land in _fullContent; the screen layer is responsible for bumping externalChangeKey when it detects that the change came from the ViewModel side (its LaunchedEffect(fullContent) compares text identity) rather than from the bridge.
Known caveat: cursor juggling on splices
When the ViewModel splices the properties drawer while the user has a selection, the splice changes offsets after the drawer. The screen's push-in path re-applies the stored selection afterwards; the selection may drift by the splice delta. This matches pre-existing behavior with OrgBodyEditor (the same drift existed there); refining it is a follow-up.
computer.whatthefuck.arcology.app.ui.components.sora.OrgBodyEditorSora
package computer.whatthefuck.arcology.app.ui.components.sora
import android.graphics.Typeface
import android.util.Log
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.viewinterop.AndroidView
import io.github.rosemoe.sora.event.ContentChangeEvent
import io.github.rosemoe.sora.event.SelectionChangeEvent
import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme
import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.subscribeAlways
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
private const val TAG = "OrgBodyEditorSora"
/**
* Bridge state shared between the sora CodeEditor and the Compose layer.
* One instance per editor; owned by OrgBodyEditorSora.
*/
class OrgSoraEditorState internal constructor(val editor: CodeEditor) {
/** Latest editor content; consumed and nulled by the debounced collector. */
val contentChanged = MutableStateFlow<TextFieldValue?>(null)
/** Live selection in absolute char indices for the wrap toolbar. */
val selectionChanged = MutableStateFlow<TextRange?>(null)
/** Language → Compose: open the timestamp picker dialog. */
val timestampPickerRequested: StateFlow<Boolean>
get() = language?.timestampPickerRequested
?: MutableStateFlow(false)
@Volatile
internal var suppressEcho = false
internal var language: OrgSoraLanguage? = null
/** Push an external splice (metadata rebuild, refile, template) into the editor. */
fun update(value: TextFieldValue) {
// Full no-op when text AND selection already agree: without this, an
// echo of our own export would setText() again, which cancels in-flight
// completions (ACTION_SET_NEW_TEXT → hide()) and resets the IME
// composing region — the source of keyboard "wonk" and popup flashes.
val currentCursor = editor.cursor
val selMin = currentCursor.left
if (editor.text.toString() == value.text && selMin == value.selection.min) {
return
}
suppressEcho = true
try {
if (editor.text.toString() != value.text) {
editor.setText(value.text)
}
val content = editor.text
// Bounds guard: the pushed TextFieldValue may carry a stale
// selection relative to the freshly replaced text (splice
// shrink, resurrected export, …) — getCharPosition's checkIndex
// turns that into a crash. Skip the selection instead;
// setText has already parked the caret at a sane position.
if (value.selection.min !in 0..content.length) {
Log.w(TAG, "update: stale selection ${value.selection.min} for length ${content.length}; selection not applied")
return
}
val pos = content.getIndexer().getCharPosition(value.selection.min)
editor.setSelection(pos.line, pos.column)
} finally {
// suppressEcho is cleared by the echo ContentChangeEvent this setText
// triggers (ACTION_SET_NEW_TEXT), handled in the composable wiring.
}
}
/** Replace [start, end) with [newText], cursor at the insertion end. */
fun applyEdit(start: Int, end: Int, newText: String) {
val content = editor.text
// Bounds guard: the caller's char offsets can be stale relative to
// the Content (external splices, IME races, leaked cleanups).
// Reject rather than translate an out-of-bounds index —
// CachedIndexer.getCharPosition runs Content.checkIndex first and
// throws.
if (start < 0 || start > end || end > content.length) {
Log.w(TAG, "applyEdit rejected: start=$start end=$end length=${content.length}")
return
}
val startPos = content.getIndexer().getCharPosition(start)
val endPos = content.getIndexer().getCharPosition(end)
content.beginBatchEdit()
try {
content.delete(startPos.line, startPos.column, endPos.line, endPos.column)
content.insert(startPos.line, startPos.column, newText)
// Local (line, column) caret arithmetic — no post-edit char index
// is computed from the pre-edit document, so a synchronous
// ContentChangeEvent subscriber mutating this content inside the
// batch cannot push the caret query out of bounds (2026-10-19
// quote-marker crash). See OrgTextActionMath's documentation.
val (line, col) = OrgTextActionMath.caretAfterInsert(startPos.line, startPos.column, newText)
// Clamp the line against the current line count before the
// column: a listener mutation inside this batch can have
// shifted line indices.
val clampedLine = line.coerceIn(0, content.getLineCount() - 1)
editor.setSelection(
clampedLine,
col.coerceAtMost(content.getColumnCount(clampedLine))
)
} finally {
content.endBatchEdit()
}
}
/** Wrap the current selection with the given prefix/suffix. */
fun wrapSelection(prefix: String, suffix: String) {
val cursor = editor.cursor
if (!cursor.isSelected) return
val content = editor.text
val selected = content.substring(cursor.getLeft(), cursor.getRight())
applyEdit(cursor.getLeft(), cursor.getRight(), prefix + selected + suffix)
}
fun closeTimestampPicker() {
language?.timestampPickerRequested?.value = false
}
}
/**
* The sora-backed org body editor.
*
* Text authority lives in the CodeEditor. [value] is applied at mount and on
* [externalChangeKey] bumps only (metadata splices, refile, template apply);
* typed changes flow out via [onValueChange], debounced by 250ms.
*/
@OptIn(FlowPreview::class)
@Composable
fun OrgBodyEditorSora(
value: TextFieldValue,
externalChangeKey: Any?,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
searchService: SearchService? = null,
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),
onSelectionChanged: ((TextRange?) -> Unit)? = null,
onTimestampPickerRequested: (Boolean) -> Unit = {},
timestampPickerConsumedKey: Any? = null,
onLinkRequest: () -> Unit = {}
) {
val context = LocalContext.current
OrgTextMateSetup.ensureInitialized(context)
val currentOnValueChange = rememberUpdatedState(onValueChange)
val currentOnSelectionChanged = rememberUpdatedState(onSelectionChanged)
val currentOnTimestampPicker = rememberUpdatedState(onTimestampPickerRequested)
val currentOnLinkRequest = rememberUpdatedState(onLinkRequest)
val state = remember {
val editor = CodeEditor(context)
val s = OrgSoraEditorState(editor)
editor.apply {
// Vulf Mono: same family the Compose editor used (R.font.vulfmono_regular)
typefaceText = try {
androidx.core.content.res.ResourcesCompat.getFont(
context, computer.whatthefuck.arcology.app.R.font.vulfmono_regular
)?.let { Typeface.create(it, Typeface.NORMAL) } ?: Typeface.MONOSPACE
} catch (_: Exception) {
Typeface.MONOSPACE
}
setLineNumberEnabled(false)
setBlockLineEnabled(false)
setWordwrap(true)
setText(value.text)
setSelection(0, 0)
colorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance()) // seeded; pushMaterialTheme() overrides after init
val lang = OrgSoraLanguage(searchService)
s.language = lang
setEditorLanguage(lang)
val completion = getComponent(io.github.rosemoe.sora.widget.component.EditorAutoCompletion::class.java)
// Full-width: the popup spans the editor width and anchors to the
// editor's bottom area instead of hugging the cursor near screen
// edges (which wrapVertical dismisses — the "cut off" popup).
completion.setCompletionWndPositionMode(
io.github.rosemoe.sora.widget.component.EditorAutoCompletion.WINDOW_POS_MODE_FULL_WIDTH_ALWAYS
)
completion.setEnabledAnimation(false)
subscribeAlways<ContentChangeEvent> { event ->
// Empty-bullet cancel runs FIRST and regardless of suppression:
// the old suppressEcho early-return consumed the echo event and
// skipped this check, letting an armed hint survive into a later
// foreign mutation (e.g. a format-action batch) whose delete+insert
// then found its (line, column) coordinates shifted mid-transaction
// — the 2026-10-19 quote-marker crash. While a *foreign* batch edit
// is open we defer (keep the hint armed) rather than delete:
// coordinates of an edit in flight must not shift.
val cancelLine = lang.pendingBulletCancelLine.value
if (cancelLine != null) {
if (editor.text.isInBatchEdit()) {
Log.i(TAG, "bullet-cancel deferred: foreign batch edit open (armed line $cancelLine)")
} else {
lang.pendingBulletCancelLine.value = null
try {
if (cancelLine < editor.text.lineCount &&
editor.text.getLineString(cancelLine).trim() == "-"
) {
editor.text.beginBatchEdit()
try {
editor.text.delete(cancelLine, 0, cancelLine + 1, 0)
} finally {
editor.text.endBatchEdit()
}
}
} catch (e: Exception) {
Log.w(TAG, "empty-bullet cleanup failed", e)
}
}
}
if (s.suppressEcho) {
s.suppressEcho = false
return@subscribeAlways
}
s.contentChanged.value = TextFieldValue(
text = editor.text.toString(),
selection = TextRange(editor.cursor.getLeft(), editor.cursor.getRight())
)
}
subscribeAlways<SelectionChangeEvent> { event ->
val left = event.left.index
val right = event.right.index
s.selectionChanged.value =
if (left == right) TextRange(left) else TextRange(left, right)
}
}
s
}
// Material-driven theming: neutral roles (background, text, selection,
// caret, line highlight, completion window) come from the Compose
// MaterialTheme (dynamic-color aware); org syntax colors are fixed per
// dark/light. Runs on initial composition and every darkTheme flip — or
// scheme change — and the TextMateColorScheme listens to ThemeRegistry so
// the editor re-colors live.
val scheme = MaterialTheme.colorScheme
LaunchedEffect(darkTheme, scheme) {
OrgTextMateSetup.applyMaterialTheme(
dark = darkTheme,
surface = scheme.surface.toArgb(),
onSurface = scheme.onSurface.toArgb(),
primary = scheme.primary.toArgb(),
surfaceVariant = scheme.surfaceVariant.toArgb()
)
state.editor.colorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance())
state.editor.invalidate()
}
// Debounced text export to Compose/ViewModel
LaunchedEffect(Unit) {
state.contentChanged
.debounce(250)
.collect { tfv ->
if (tfv != null) {
currentOnValueChange.value(tfv)
state.contentChanged.value = null
}
}
}
// External splices in: only when the key is bumped
LaunchedEffect(externalChangeKey) {
state.update(value)
}
// Selection forwarding to the wrap-toolbar owner
LaunchedEffect(Unit) {
state.selectionChanged.collect { range ->
currentOnSelectionChanged.value?.invoke(range)
}
}
// Timestamp picker request routing: language flags true → Compose opens the
// dialog; Compose bumps the consumed key → bridge closes the flag.
val timestampRequested by state.timestampPickerRequested.collectAsState()
LaunchedEffect(timestampRequested) {
currentOnTimestampPicker.value(timestampRequested)
}
LaunchedEffect(timestampPickerConsumedKey) {
if (timestampPickerConsumedKey != null) {
state.closeTimestampPicker()
}
}
DisposableEffect(Unit) {
// Selection panel replacement: clipboard actions + org formatting,
// replacing sora's default EditorTextActionWindow component.
val editor = state.editor
editor.replaceComponent(
io.github.rosemoe.sora.widget.component.EditorTextActionWindow::class.java,
OrgTextActionWindow(
editor,
listOf(
OrgTextActionWindow.FormatAction("*", "*", "*"),
OrgTextActionWindow.FormatAction("/", "/", "/"),
OrgTextActionWindow.FormatAction("=", "=", "="),
OrgTextActionWindow.FormatAction("~", "~", "~"),
OrgTextActionWindow.FormatAction("Q", "#+BEGIN_QUOTE\n", "\n#+END_QUOTE"),
OrgTextActionWindow.FormatAction("[[L]]") { currentOnLinkRequest.value() },
OrgTextActionWindow.FormatAction("{{C}}", "{{", "}")
)
)
)
onDispose { state.editor.release() }
}
AndroidView(
factory = { state.editor },
modifier = modifier
)
}The subscription receipts returned by subscribeAlways are retained by the editor and released with it in release(). Exposing OrgSoraEditorState publicly lets EditingView call applyEdit / wrapSelection from its toolbar and closeTimestampPicker after opening the dialog.
OrgTextActionWindow — org formatting in the selection panel
The selection text-action panel (select/copy/cut/paste when you grab a handle) is a replaceable builtin component. We replace EditorTextActionWindow with OrgTextActionWindow: the same clipboard row, plus org format actions (bold, italic, underline, verbatim, code, quote, link). This retires the Compose floating toolbar — formatting lives where the selection happens.
Upstream's action buttons are private and wired to its own layout; the sanctioned customization is replacing the whole EditorBuiltinComponent. This implementation builds a text-button LinearLayout row in code (no XML resource needed), wires selection/scroll/handle-state events like upstream, and routes format actions either directly on the editor's Content or through an optional callback the bridge supplies.
computer.whatthefuck.arcology.app.ui.components.sora.OrgTextActionWindow
package computer.whatthefuck.arcology.app.ui.components.sora
import android.util.Log
import android.view.Gravity
import android.widget.LinearLayout
import android.widget.TextView
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.component.EditorTextActionWindow
/**
* Pure caret arithmetic for region insertions. JVM-testable, so the wrap/quote
* caret math is verified without instantiating the View class hierarchy.
*
* Sora Content coordinates are (line, column) pairs. Inserting [text] at
* (baseLine, baseColumn) keeps the original line's [0, baseColumn) head on
* that line and pushes the original tail behind the inserted text, so the
* caret right after the insertion sits on:
* - line = baseLine + the number of '\n' characters inside text
* - column = baseColumn + text.length, when text contains no '\n'
* - column = the length of the segment after text's last '\n' otherwise
* (the original tail resumes immediately after, on the same line)
*
* Deliberately NEVER consults a global char index or the document length: a
* synchronous ContentChangeEvent subscriber (e.g. the empty-bullet cleanup)
* can mutate the document inside our edit batch — any char index derived
* from the pre-edit document is then stale, and Content.checkIndex throws
* instead of answering a position (the 2026-10-19 quote-marker crash,
* [[file:editor/sora-bridge.org::*Known tradeoff: suppressEcho can leak the
* pending bullet-cancel][suppressEcho leak]]). Local facts alone cannot run
* out of bounds: the inserted tail always exists on that line by
* construction of Content.insert().
*
* Callers clamp the returned pair against the CURRENT line/column counts
* before setSelection: a listener mutation inside the edit batch can shift
* line indices after the insert. Clamp the LINE against lineCount first —
* otherwise getColumnCount(caretLine) itself can throw.
*/
object OrgTextActionMath {
/** (line, column) right after inserting [text] at the given base position. */
fun caretAfterInsert(baseLine: Int, baseColumn: Int, text: CharSequence): Pair<Int, Int> {
// Hand-rolled scan instead of the Char-delimiter stdlib substring
// helpers: those CharSequence variants are not guaranteed to resolve
// in this build's Kotlin stdlib configuration.
var lastNewline = -1
var newlines = 0
text.forEachIndexed { i, c ->
if (c == '\n') {
newlines++
lastNewline = i
}
}
return if (lastNewline < 0) {
// No '\n' in text: the insertion stays on the base line.
baseLine to (baseColumn + text.length)
} else {
// Lines split: the caret's line starts with the segment after
// text's last '\n'; the original tail resumes there on the line.
(baseLine + newlines) to (text.length - lastNewline - 1)
}
}
}
/**
* Selection action panel: sora's default clipboard row (select-all/copy/paste/
* cut/long-select — kept from the upstream layout) plus appended org format
* actions (bold, italic, underline, verbatim, code, quote, link).
*
* Subclasses the builtin so CodeEditor.replaceComponent accepts it; the
* upstream row is reused via its root view (panel_root > panel_hv > row), and
* our buttons are appended to that same scroll row. Format actions edit the
* editor's Content directly and place the caret with [OrgTextActionMath]'s
* local (line, column) arithmetic — see that object's documentation for why
* no global char index may be consulted after the mutation.
*/
class OrgTextActionWindow(
editor: CodeEditor,
private val formatActions: List<FormatAction>
) : EditorTextActionWindow(editor) {
data class FormatAction(
val label: CharSequence,
val prefix: String = "",
val suffix: String = "",
/** When set, overrides the default wrap behavior (e.g. open node picker). */
val onClick: (() -> Unit)? = null
)
companion object {
private const val TAG = "OrgTextActionWindow"
}
init {
val root = getView()
// root = LinearLayout(panel_root) > HorizontalScrollView(panel_hv) > rows
var scrollRow: LinearLayout? = null
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is android.widget.HorizontalScrollView) {
val inner = child.getChildAt(0)
if (inner is LinearLayout) {
scrollRow = inner
}
}
}
scrollRow?.let { row ->
for (action in formatActions) {
val tv = TextView(editor.context).apply {
text = action.label
gravity = Gravity.CENTER
val pad = (10 * editor.dpUnit).toInt()
setPadding(pad, 0, pad, 0)
}
tv.setOnClickListener {
val custom = action.onClick
if (custom != null) {
custom.invoke()
} else {
val sel = editor.cursor
if (sel.isSelected) {
val content = editor.text
val selected = content.substring(sel.left, sel.right)
val inserted = action.prefix + selected + action.suffix
content.beginBatchEdit()
try {
Log.i(
TAG,
"wrap label=${action.label} sel=[${sel.left},${sel.right}) " +
"pos=(${sel.leftLine},${sel.leftColumn})..(${sel.rightLine},${sel.rightColumn}) " +
"lenBefore=${content.length} insertedLen=${inserted.length}"
)
content.delete(sel.leftLine, sel.leftColumn, sel.rightLine, sel.rightColumn)
// Content.insert clamps a separator-position column
// to the line content; mirror that clamp so the caret
// arithmetic below uses the coordinates sora actually
// adopted for the insertion.
val insertColumn = sel.leftColumn.coerceAtMost(content.getColumnCount(sel.leftLine))
content.insert(sel.leftLine, insertColumn, inserted)
Log.i(TAG, "wrap inserted, lenAfter=${content.length}")
// Local (line, column) caret arithmetic — the
// pre-edit char index must not be consulted after
// the mutation; see OrgTextActionMath's docs.
val (caretLine, caretColumn) =
OrgTextActionMath.caretAfterInsert(sel.leftLine, insertColumn, inserted)
// Clamp the line against the CURRENT line count
// before the column: a listener mutation inside
// this batch can have shifted line indices.
val clampedLine = caretLine.coerceIn(0, content.getLineCount() - 1)
val clampedColumn = caretColumn.coerceAtMost(content.getColumnCount(clampedLine))
if (clampedLine != caretLine || clampedColumn != caretColumn) {
Log.w(TAG, "wrap caret clamped ($caretLine,$caretColumn) -> ($clampedLine,$clampedColumn)")
}
Log.i(TAG, "wrap caret=($clampedLine,$clampedColumn)")
editor.setSelection(clampedLine, clampedColumn)
} finally {
content.endBatchEdit()
}
}
}
}
row.addView(tv)
}
}
}
}Tests
JVM tests for OrgLinkQuery and OrgTextActionMath, the components with testable pure logic. OrgTextActionMath tests also drive a real sora Content object — the text package carries no android.* runtime code (bidi machinery is annotation-only and disabled), so the 2026-10-19 quote-marker crash shape — caret arithmetic invalidated by a mid-batch listener mutation — has a JVM-level regression test. The language/bridge classes depend on Android widgets; their behavior is exercised on-device (see On-device validation).
computer.whatthefuck.arcology.app.ui.components.sora.OrgLinkQueryTest
package computer.whatthefuck.arcology.app.ui.components.sora
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class OrgLinkQueryTest {
// pendingLinkQuery
@Test
fun `no open bracket yields null`() {
assertNull(OrgLinkQuery.pendingLinkQuery("just some text"))
}
@Test
fun `open bracket with query returns query`() {
assertEquals("note titl", OrgLinkQuery.pendingLinkQuery("see [[note titl"))
}
@Test
fun `closing bracket closes the context`() {
assertNull(OrgLinkQuery.pendingLinkQuery("see [[a note]] more"))
}
@Test
fun `alias separator closes the context`() {
assertNull(OrgLinkQuery.pendingLinkQuery("see [[target][title"))
}
@Test
fun `query spanning the trigger window boundary is not found`() {
val long = "x".repeat(OrgLinkQuery.TRIGGER_WINDOW + 50)
assertNull(OrgLinkQuery.pendingLinkQuery("[[$long"))
}
// shouldTriggerTimestamp
@Test
fun `lone less-than triggers`() {
assertTrue(OrgLinkQuery.shouldTriggerTimestamp('<', "SCHEDULED: <"))
}
@Test
fun `non-less-than character never triggers`() {
assertFalse(OrgLinkQuery.shouldTriggerTimestamp('a', "a"))
}
@Test
fun `less-than inside open link does not trigger`() {
assertFalse(OrgLinkQuery.shouldTriggerTimestamp('<', "see [[incomplete<"))
}
@Test
fun `less-than inside closed link triggers`() {
assertTrue(OrgLinkQuery.shouldTriggerTimestamp('<', "see [[a]] and <"))
}
@Test
fun `second less-than within unterminated timestamp does not trigger`() {
assertFalse(OrgLinkQuery.shouldTriggerTimestamp('<', "<2026-08-15<"))
}
@Test
fun `less-than after a completed timestamp triggers`() {
assertTrue(OrgLinkQuery.shouldTriggerTimestamp('<', "<2026-08-15 Fri> <"))
}
// listContinuation
@Test
fun `plain line gives null`() {
assertNull(OrgLinkQuery.listContinuation("not a list"))
}
@Test
fun `list item returns prefix`() {
assertEquals("- ", OrgLinkQuery.listContinuation("- existing item"))
}
@Test
fun `indented list item inherits indentation`() {
assertEquals(" - ", OrgLinkQuery.listContinuation(" - nested"))
}
@Test
fun `empty bullet requests removal`() {
assertEquals("", OrgLinkQuery.listContinuation("-"))
}
@Test
fun `indented empty bullet requests removal`() {
assertEquals("", OrgLinkQuery.listContinuation(" -"))
}
}computer.whatthefuck.arcology.app.ui.components.sora.OrgTextActionMathTest
package computer.whatthefuck.arcology.app.ui.components.sora
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.text.ContentListener
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class OrgTextActionMathTest {
// caretAfterInsert — pure (line, column) arithmetic
@Test
fun `single marker wrap keeps caret on the base line after suffix`() {
assertEquals(2 to 12, OrgTextActionMath.caretAfterInsert(2, 5, "*hello*"))
}
@Test
fun `quote wrap parks caret at the tail of the block on a new line`() {
val inserted = "#+BEGIN_QUOTE\nhello\n#+END_QUOTE"
assertEquals(4 to 11, OrgTextActionMath.caretAfterInsert(2, 0, inserted))
}
@Test
fun `quote wrap at non-zero base column uses the suffix length for caret column`() {
val inserted = "#+BEGIN_QUOTE\nhello\n#+END_QUOTE"
// The base column was consumed by the insertion's first line; the
// caret column on the final inserted line is the suffix length.
assertEquals(4 to 11, OrgTextActionMath.caretAfterInsert(2, 5, inserted))
}
@Test
fun `selection containing newlines shifts caret line accordingly`() {
val inserted = "#+BEGIN_QUOTE\na\nb\n#+END_QUOTE"
assertEquals(4 to 11, OrgTextActionMath.caretAfterInsert(1, 3, inserted))
}
@Test
fun `selection with trailing newline adds an empty intermediate line`() {
val inserted = "#+BEGIN_QUOTE\nbravo\n\n#+END_QUOTE"
assertEquals(3 to 11, OrgTextActionMath.caretAfterInsert(0, 0, inserted))
}
@Test
fun `inserted text ending in newline parks caret at column zero`() {
assertEquals(3 to 0, OrgTextActionMath.caretAfterInsert(2, 4, "abc\n"))
}
@Test
fun `empty insertion is a no-op caret`() {
assertEquals(2 to 4, OrgTextActionMath.caretAfterInsert(2, 4, ""))
}
@Test
fun `surrogate pairs in selected text do not perturb newline counting`() {
val inserted = "#+BEGIN_QUOTE\nmap\ud83d\ude00\n#+END_QUOTE"
assertEquals(2 to 11, OrgTextActionMath.caretAfterInsert(0, 0, inserted))
}
// Content-level regression: the 2026-10-19 quote-marker crash
/**
* Simulates the leaked empty-bullet cleanup: a [ContentListener] that
* deletes a whole line while a foreign region-edit batch is open, during
* the batch's insert dispatch. Mirrors the real handler's consume-first
* protocol (the hint is consumed before its delete, so a nested dispatch
* cannot re-trigger it).
*/
class LineDeletingListener(private val lineToDelete: Int) : ContentListener {
var ran = false
override fun beforeReplace(content: Content) {}
override fun afterInsert(
content: Content,
startLine: Int,
startColumn: Int,
endLine: Int,
endColumn: Int,
insertedContent: CharSequence
) {
if (ran) return
ran = true
content.delete(lineToDelete, 0, lineToDelete + 1, 0)
}
override fun afterDelete(
content: Content,
startLine: Int,
startColumn: Int,
endLine: Int,
endColumn: Int,
deletedContent: CharSequence
) {}
}
/**
* The crash: user selects through end of document, the wrap's insert
* event dispatches a leaked cleanup that deletes 3 characters, and the
* old caret query (~pre-edit sel.right + inserted length~) then asked
* ~getCharPosition(49)~ of a ~46~-character document —
* ~Content.checkIndex~ throws on ~index > length~. The local arithmetic
* stays addressable and clamps instead.
*/
@Test
fun `wrap caret survives a mid-batch line deletion by a listener`() {
val content = Content("alpha\nbravo\n- \ncharlie\n")
// In the post-insert document the armed "-" bullet is line 3.
val listener = LineDeletingListener(3)
content.addContentListener(listener)
val selLeft = 6
val selRight = content.length // selection through end of document
val selected = content.substring(selLeft, selRight)
assertEquals("bravo\n- \ncharlie\n", selected)
val inserted = "#+BEGIN_QUOTE\n" + selected + "\n#+END_QUOTE"
// The old code computed the caret from the pre-edit document:
// sel.left + inserted.length = 6 + 43 = 49.
val oldArithmetic = selLeft + inserted.length
val startLine = 1
val startColumn = 0
content.beginBatchEdit()
try {
content.delete(startLine, startColumn, 4, 0)
content.insert(startLine, startColumn, inserted)
// Listener fired inside the insert dispatch; document shrank.
assertTrue(listener.ran)
assertTrue("crash shape: old arithmetic outruns the shrunk document",
oldArithmetic > content.length)
// The local arithmetic itself is independent of the mutation and
// stays structurally addressable. The insertion splits into
// "#+BEGIN_QUOTE / bravo / "- " / charlie / (empty, the doubled
// newline) / "#+END_QUOTE" — five newlines, so the unshifted
// caret line is 6.
val (line, col) = OrgTextActionMath.caretAfterInsert(startLine, startColumn, inserted)
assertEquals(6, line)
assertEquals(11, col)
// setSelection equivalent: clamp the LINE first (the listener
// delete shifted line indices), THEN the column.
val clampedLine = line.coerceIn(0, content.getLineCount() - 1)
val clampedColumn = col.coerceAtMost(content.getColumnCount(clampedLine))
// The deleted bullet shifted the careted line up by one, and the
// clamp lands it exactly on the closing marker's tail.
assertEquals(5, clampedLine)
assertEquals("#+END_QUOTE", content.getLineString(clampedLine))
assertEquals(11, clampedColumn)
} finally {
content.endBatchEdit()
}
// The leaked cleanup removed the armed empty bullet; the wrap is intact.
assertTrue(content.toString().contains("#+BEGIN_QUOTE\n"))
}
/**
* Happy path on a real Content: the caret lands right after the closing
* marker, and sora's own indexer agrees with the local arithmetic.
*/
@Test
fun `wrap on real Content puts caret after the closing marker`() {
val content = Content("alpha\nbravo\n- \ncharlie\n")
val selLeft = 6
val selRight = 11
val selected = content.substring(selLeft, selRight)
assertEquals("bravo", selected)
val inserted = "#+BEGIN_QUOTE\n" + selected + "\n#+END_QUOTE"
val startLine = 1
val startColumn = 0
content.delete(startLine, startColumn, 1, 5)
content.insert(startLine, startColumn, inserted)
val (line, col) = OrgTextActionMath.caretAfterInsert(startLine, startColumn, inserted)
assertEquals(3, line)
assertEquals(11, col)
assertEquals("#+END_QUOTE", content.getLineString(line))
// Cross-check against sora's own index translation: absolute index of
// line 1's start plus the inserted length.
val pos = content.getIndexer()
.getCharPosition(content.getCharIndex(startLine, startColumn) + inserted.length)
assertEquals(line, pos.line)
assertEquals(col, pos.column)
}
}Integration into screen.org
EditingView in editor/screen.org mounts OrgBodyEditorSora (editor fills the pane, scrolls internally — the outer verticalScroll and the scroll-to-cursor plumbing are Editing-mode concerns that dissolve), keeps the floating wrap toolbar via the bridge's selection flow, and hosts the metadata configurator as one bottom sheet. See the diff narrative in screen.org.
On-device validation
Not runnable from the agent: install the debug build, open your longest node, type. If still slow, capture Perfetto and look at which frames exceed budget.
Related Modules
screen.org — EditingView hosts this composably
viewmodel.org — updateFullContent/save pipeline unchanged
body-editor.org — the Compose editor this replaces for editing; still used for single-line capture fields
indexer.org — SearchService behind link completion