: 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.
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
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
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)
val after = content.getIndexer().getCharPosition(start + newText.length)
editor.setSelection(after.line, after.column)
} 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 ->
if (s.suppressEcho) {
s.suppressEcho = false
return@subscribeAlways
}
s.contentChanged.value = TextFieldValue(
text = editor.text.toString(),
selection = TextRange(editor.cursor.getLeft(), editor.cursor.getRight())
)
// Empty-bullet cancel (two-phase, see module narrative)
val cancelLine = lang.pendingBulletCancelLine.value
if (cancelLine != null) {
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)
}
}
}
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.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
/**
* 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 route
* through [OrgSoraEditorState] helpers so text edits participate in Compose
* state sync.
*/
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
)
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)
content.beginBatchEdit()
try {
content.delete(sel.leftLine, sel.leftColumn, sel.rightLine, sel.rightColumn)
content.insert(sel.leftLine, sel.leftColumn, action.prefix + selected + action.suffix)
val newPos = content.getIndexer().getCharPosition(
sel.left + action.prefix.length + selected.length + action.suffix.length
)
editor.setSelection(newPos.line, newPos.column)
} finally {
content.endBatchEdit()
}
}
}
}
row.addView(tv)
}
}
}
}Tests
JVM tests for OrgLinkQuery, the only component with testable pure logic. 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(" -"))
}
}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