OrgBodyEditor is the composable text field used by EditingView. It wraps a Compose BasicTextField with three org-specific enhancements:
OrgVisualTransformation— renders markup (bolding, italic, etc.) inline while typingLink autocomplete — when the user types two opening brackets, a debounced FTS popup appears suggesting node IDs
List continuation — pressing
Enterafter a list item (- item) auto-inserts the next bullet
OrgBodyEditor
Design: Visual transformation over rich text
Rather than maintaining a parallel rich-text model, OrgVisualTransformation operates on the raw text. The user sees styled text but types plain org markup. This is close to how Emacs does Org editing. This does not have concise link rendering like Org does, but for now that's alright.
Design: Link autocomplete via debounced FTS
When two opening brackets are detected in the text, the link autocomplete popup shows search results from SearchService. The 200ms debounce prevents flooding the database. Results are displayed as LazyColumn list items with title + file.
Design: List continuation
After pressing Enter on a line that matches the list item pattern (- prefix), the editor auto-inserts a matching bullet. If the user presses Enter on an empty bullet (- alone), it removes the empty list item instead — treating it as a cancel action.
Design: Cursor scroll-to-keep-visible
When the parent scrollState is provided, OrgBodyEditor detects cursor position changes and smoothly scrolls to keep the cursor visible. This works by reading TextLayoutResult after each layout pass.
arcology.app.ui.components.OrgBodyEditor
package computer.whatthefuck.arcology.app.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
// Regex to detect list item lines
private val listItemPattern = Regex("""^(\s*)-\s""")
/**
* Handles list continuation when Enter is pressed after a list item.
* Returns the modified TextFieldValue if list continuation was applied, null otherwise.
*/
private fun handleListContinuation(
oldValue: TextFieldValue,
newValue: TextFieldValue
): TextFieldValue? {
val oldText = oldValue.text
val newText = newValue.text
// Check if a newline was just inserted
if (newText.length != oldText.length + 1) return null
val cursorPos = newValue.selection.start
if (cursorPos < 1) return null
// Find where the newline was inserted
val insertPos = cursorPos - 1
if (insertPos >= newText.length || newText[insertPos] != '\n') return null
// Get the line before the newline
val beforeNewline = newText.substring(0, insertPos)
val lastLineStart = beforeNewline.lastIndexOf('\n') + 1
val previousLine = beforeNewline.substring(lastLineStart)
// Check if the previous line is a list item
val listMatch = listItemPattern.find(previousLine) ?: return null
// If the previous line is an empty list item (just "- " or similar), remove it instead
val trimmedLine = previousLine.trim()
if (trimmedLine == "-") {
// Remove the empty list item and newline
val modifiedText = newText.substring(0, lastLineStart) + newText.substring(cursorPos)
return TextFieldValue(
text = modifiedText,
selection = TextRange(lastLineStart)
)
}
// Insert the list prefix after the newline
val indent = listMatch.groupValues[1]
val listPrefix = "$indent- "
val modifiedText = newText.substring(0, cursorPos) + listPrefix + newText.substring(cursorPos)
val newCursor = cursorPos + listPrefix.length
return TextFieldValue(
text = modifiedText,
selection = TextRange(newCursor)
)
}
/**
* Reusable org-mode body editor composable.
*
* Provides:
* - Monospace BasicTextField with OrgVisualTransformation (hybrid markup rendering)
* - Link autocomplete popup (when searchService is provided) — triggered by typing two opening brackets
* - List continuation: pressing Enter after `- item` adds `- ` on the next line (multi-line only)
*
* @param value Current TextFieldValue (text + selection)
* @param onValueChange Called when text or selection changes
* @param modifier Modifier for the outer Box
* @param searchService Optional SearchService to enable link autocomplete
* @param singleLine When true, restricts input to a single line
* @param placeholder Optional placeholder text shown when field is empty
* @param onLinkClick Called when a link is tapped (only in read mode)
* @param scrollState Optional ScrollState of parent container to scroll and keep cursor visible
*/
@OptIn(FlowPreview::class)
@Composable
fun OrgBodyEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
searchService: SearchService? = null,
singleLine: Boolean = false,
placeholder: String? = null,
onLinkClick: ((nodeId: String) -> Unit)? = null,
scrollState: androidx.compose.foundation.ScrollState? = null
) {
val visualTransformation = remember {
OrgVisualTransformation()
}
// Track text layout for tap-to-link detection
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
// Parse links from current text, but only when the caller actually wants
// tap-to-link behavior. In EditingView onLinkClick is null, so this skips a
// full-text regex scan on every keystroke.
val parsedLinks = if (onLinkClick != null) {
remember(value.text) { parseLinks(value.text) }
} else {
emptyList()
}
// Track cursor position for scrolling
var cursorOffsetForScroll by remember { mutableStateOf<Int?>(null) }
val density = LocalDensity.current
// Scroll to cursor after layout updates
LaunchedEffect(textLayoutResult, cursorOffsetForScroll, scrollState) {
if (textLayoutResult != null && cursorOffsetForScroll != null && scrollState != null && !singleLine) {
val offset = cursorOffsetForScroll!!
val layout = textLayoutResult!!
// Get cursor line
val line = layout.getLineForOffset(offset)
if (line >= 0 && line < layout.lineCount) {
// Get line top position in pixels (getLineTop returns Float in pixels)
val lineTopPx = layout.getLineTop(line).toInt()
// Estimate viewport height (assume ~20 lines visible at ~24px each)
val estimatedLineHeight = 48 // pixels for 16sp font with line height
val viewportPx = estimatedLineHeight * 12
// Calculate target scroll position - center the cursor line
val targetScroll = (lineTopPx - viewportPx / 2).coerceAtLeast(0)
// Only scroll if cursor is outside current viewport
val currentScroll = scrollState.value
val scrollThreshold = viewportPx / 3
if (lineTopPx > currentScroll + viewportPx - scrollThreshold) {
// Cursor below viewport - scroll down
scrollState.animateScrollTo((lineTopPx - viewportPx + scrollThreshold).coerceAtLeast(0))
} else if (lineTopPx < currentScroll) {
// Cursor above viewport - scroll up
scrollState.animateScrollTo(targetScroll)
}
}
cursorOffsetForScroll = null
}
}
// Link autocomplete state
val linkQueryFlow = remember { MutableStateFlow<String?>(null) }
val linkResults by remember(searchService) {
linkQueryFlow
.debounce(200)
.map { query ->
if (query.isNullOrBlank() || searchService == null) {
emptyList()
} else {
try {
searchService.searchPrimary(query, 10)
} catch (_: Exception) {
emptyList()
}
}
}
}.collectAsState(initial = emptyList())
val linkQuery by linkQueryFlow.collectAsState()
// Detect link trigger — when user types two opening brackets, launch autocomplete
fun detectLinkTrigger(tfv: TextFieldValue) {
if (searchService == null) return
val text = tfv.text
val cursor = tfv.selection.start
if (cursor < 2 || tfv.selection.start != tfv.selection.end) {
linkQueryFlow.value = null
return
}
val beforeCursor = text.substring(0, cursor)
val lastOpen = beforeCursor.lastIndexOf("[[")
if (lastOpen == -1) {
linkQueryFlow.value = null
return
}
val between = beforeCursor.substring(lastOpen + 2)
if (between.contains("]]") || between.contains("][")) {
linkQueryFlow.value = null
return
}
linkQueryFlow.value = between
}
// Insert a completed link
fun insertLink(targetNodeId: String, title: String) {
val text = value.text
val cursor = value.selection.start
val beforeCursor = text.substring(0, cursor)
val lastOpen = beforeCursor.lastIndexOf("[[")
if (lastOpen == -1) return
val linkText = "[[id:$targetNodeId][$title]]"
val newText = text.substring(0, lastOpen) + linkText + text.substring(cursor)
val newCursor = lastOpen + linkText.length
onValueChange(TextFieldValue(text = newText, selection = TextRange(newCursor)))
linkQueryFlow.value = null
}
// Timestamp insertion state — triggered by typing a lone '<' (not inside [[...]])
var showTimestampPicker by remember { mutableStateOf(false) }
var timestampInsertPos by remember { mutableStateOf(-1) }
// Detect '<' trigger — when user types a '<' that's not part of [[, launch picker
fun detectTimestampTrigger(oldTfv: TextFieldValue, newTfv: TextFieldValue) {
if (singleLine) return
if (newTfv.selection.start != newTfv.selection.end) return
val cursor = newTfv.selection.start
if (cursor < 1) return
// Must have just inserted a '<'
if (newTfv.text.length != oldTfv.text.length + 1) return
if (newTfv.text.getOrNull(cursor - 1) != '<') return
// Don't trigger if inside a link: look back for [[ without closing ]]
val beforeCursor = newTfv.text.substring(0, cursor - 1)
val lastOpen = beforeCursor.lastIndexOf("[[")
if (lastOpen != -1) {
val between = beforeCursor.substring(lastOpen + 2)
if (!between.contains("]]") && !between.contains("][")) {
// Inside an open link — don't trigger
return
}
}
// Don't trigger if already inside an incomplete timestamp (preceded by another '<' without '>')
val lastClose = beforeCursor.lastIndexOf('>')
val lastOpenTs = beforeCursor.lastIndexOf('<')
if (lastOpenTs > lastClose) return
timestampInsertPos = cursor - 1
showTimestampPicker = true
}
// Insert a completed active timestamp at the '<' position
fun insertTimestamp(date: LocalDate, time: LocalTime?) {
val insertPos = timestampInsertPos
if (insertPos < 0 || insertPos > value.text.length) {
showTimestampPicker = false
return
}
val tsString = PlanningInfoUtils.formatTimestamp(
date = date,
time = time,
isActive = true,
repeater = null,
showWeekday = true
)
val text = value.text
// Replace the lone '<' with the full timestamp string
val newText = text.substring(0, insertPos) + tsString + text.substring(insertPos + 1)
val newCursor = insertPos + tsString.length
onValueChange(TextFieldValue(text = newText, selection = TextRange(newCursor)))
showTimestampPicker = false
timestampInsertPos = -1
}
Box(
modifier = modifier
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(4.dp)
)
) {
BasicTextField(
value = value,
onValueChange = { newValue ->
// Check for list continuation (only in multi-line mode)
val continuedValue = if (!singleLine) handleListContinuation(value, newValue) else null
val finalValue = continuedValue ?: newValue
onValueChange(finalValue)
detectLinkTrigger(finalValue)
detectTimestampTrigger(value, finalValue)
// Store cursor offset for scroll calculation after layout
if (!singleLine && scrollState != null) {
cursorOffsetForScroll = finalValue.selection.start
}
},
onTextLayout = { layoutResult ->
textLayoutResult = layoutResult
},
singleLine = singleLine,
modifier = Modifier
.fillMaxWidth()
.then(if (!singleLine) Modifier.defaultMinSize(minHeight = 120.dp) else Modifier)
.let { mod ->
if (onLinkClick != null) {
mod.pointerInput(parsedLinks) {
detectTapGestures { offset ->
textLayoutResult?.let { layoutResult ->
val textOffset = layoutResult.getOffsetForPosition(offset)
// Check if tap is on a link
parsedLinks.find { link ->
textOffset >= link.startOffset && textOffset < link.endOffset
}?.let { link ->
if (link.isIdLink) {
onLinkClick(link.target)
}
}
}
}
}
} else mod
},
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
fontFamily = VulfMono
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = visualTransformation,
decorationBox = { innerTextField ->
Box(modifier = Modifier.padding(8.dp)) {
if (value.text.isEmpty() && placeholder != null) {
Text(
text = placeholder,
style = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = VulfMono
)
)
}
innerTextField()
}
}
)
// Link autocomplete popup
if (linkQuery != null && linkResults.isNotEmpty()) {
Popup(
alignment = Alignment.TopStart,
properties = PopupProperties(focusable = false)
) {
Card(
modifier = Modifier
.widthIn(max = 300.dp)
.heightIn(max = 250.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
LazyColumn {
items(linkResults, key = { it.node.id }) { result ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
insertLink(
result.node.id,
result.node.title ?: "Untitled"
)
}
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = result.node.title ?: "Untitled",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = result.node.file,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
}
}
}
}
}
// Timestamp picker popup
if (showTimestampPicker) {
TimestampPickerPopup(
onInsert = { date, time -> insertTimestamp(date, time) },
onDismiss = { showTimestampPicker = false }
)
}
}
}
/**
* Popup with a date picker and optional time field for inserting an active
* org timestamp. Triggered by typing '<' in the OrgBodyEditor.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TimestampPickerPopup(
onInsert: (LocalDate, LocalTime?) -> Unit,
onDismiss: () -> Unit
) {
val today = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date }
var selectedDate by remember { mutableStateOf(today) }
var timeEnabled by remember { mutableStateOf(false) }
var timeText by remember { mutableStateOf("09:00") }
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
)
Popup(
alignment = Alignment.TopStart,
properties = PopupProperties(focusable = true)
) {
Card(
modifier = Modifier
.widthIn(max = 340.dp)
.padding(4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Insert timestamp", style = MaterialTheme.typography.labelMedium)
DatePicker(state = datePickerState)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = timeEnabled,
onClick = { timeEnabled = !timeEnabled },
label = { Text("Time") }
)
if (timeEnabled) {
OutlinedTextField(
value = timeText,
onValueChange = { timeText = it },
label = { Text("HH:mm") },
singleLine = true,
modifier = Modifier.width(100.dp)
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Button(onClick = {
val date = datePickerState.selectedDateMillis?.let {
LocalDate.fromEpochDays((it / 86400000L).toInt())
} ?: today
val time = if (timeEnabled) {
runCatching {
val (h, m) = timeText.split(":").map { it.toInt() }
LocalTime(h, m)
}.getOrNull()
} else null
onInsert(date, time)
}) { Text("Insert") }
OutlinedButton(onClick = onDismiss) { Text("Cancel") }
}
}
}
}
}OrgVisualTransformation — Inline markup rendering in the text field
A Compose VisualTransformation that applies org-mode inline markup styling (bold, italic, verbatim, code, link patterns, #+BEGIN_/END_ blocks, headings, and #+KEYWORDS) while keeping all markup characters visible.
Design decision: this operates on the raw text rather than maintaining a parallel rich-text model. The user sees styled text but types plain org markup — similar to how Emacs renders Org editing buffers. The visual transformation is purely cosmetic; the stored text remains plain org.
Design: Single-pass span collection
OrgVisualTransformation.filter() runs on the UI thread for every keystroke, so its cost scales linearly with document length. The original implementation called Regex(...) inside each apply* helper, recompiling 7 patterns on every invocation. The current version pre-compiles all patterns once per instance (the transformation is =remember=ed at the call site) and collects all match ranges into a single list before applying them to the AnnotatedString.Builder in one sorted pass. This removes the per-keystroke regex compilation that was the dominant main-thread cost on long documents.
arcology.app.ui.components.OrgVisualTransformation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
class OrgVisualTransformation(
private val linkColor: Color = Color(0xFF6699CC),
private val verbatimColor: Color = Color(0xFF8FAA54),
private val codeColor: Color = Color(0xFFCC8844),
private val blockKeywordColor: Color = Color(0xFF888888),
private val headingColor: Color = Color(0xFF5588BB),
private val keywordColor: Color = Color(0xFF888888)
) : VisualTransformation {
// Pre-compiled patterns — built once per OrgVisualTransformation instance
// (which is `remember`ed at the call site), not per keystroke.
private val inlineMarkupPatterns: List<Pair<Regex, SpanStyle>> = listOf(
Regex("(?<=^|[\\s({\"])\\*([^*\\n]+)\\*(?=\$|[\\s)}.,:;!?\"'])") to
SpanStyle(fontWeight = FontWeight.Bold),
Regex("(?<=^|[\\s({\"])/([^/\\n]+)/(?=\$|[\\s)}.,:;!?\"'])") to
SpanStyle(fontStyle = FontStyle.Italic),
Regex("(?<=^|[\\s({\"])=([^=\\n]+)=(?=\$|[\\s)}.,:;!?\"'])") to
SpanStyle(fontFamily = VulfMono, color = verbatimColor),
Regex("(?<=^|[\\s({\"])~([^~\\n]+)~(?=\$|[\\s)}.,:;!?\"'])") to
SpanStyle(fontFamily = VulfMono, color = codeColor)
)
private val linkPattern = Regex("\\[\\[[^]]+]]")
private val blockPattern = Regex("^#\\+(?:BEGIN|END)_\\w+.*\$", RegexOption.MULTILINE)
private val headingPattern = Regex("^(\\*+)\\s+(.*)\$", RegexOption.MULTILINE)
private val keywordPattern = Regex("^#\\+([A-Za-z_]+):\\s*(.*)\$", RegexOption.MULTILINE)
private data class Span(val start: Int, val end: Int, val style: SpanStyle)
override fun filter(text: AnnotatedString): TransformedText {
val raw = text.text
val builder = AnnotatedString.Builder(raw)
// Collect every match from every pattern into one list, then sort and
// apply. This walks the text 7 times still, but the regex objects
// themselves are no longer recompiled on every call — that was the
// dominant cost on the main thread. Sorting keeps style application
// cache-friendly on the AnnotatedString.Builder.
val spans = ArrayList<Span>(64)
for ((pattern, style) in inlineMarkupPatterns) {
for (match in pattern.findAll(raw)) {
spans.add(Span(match.range.first, match.range.last + 1, style))
}
}
for (match in linkPattern.findAll(raw)) {
spans.add(Span(match.range.first, match.range.last + 1, SpanStyle(color = linkColor)))
}
for (match in blockPattern.findAll(raw)) {
spans.add(Span(match.range.first, match.range.last + 1, SpanStyle(color = blockKeywordColor)))
}
for (match in headingPattern.findAll(raw)) {
val level = match.groupValues[1].length
val fontSize = when (level) {
1 -> 24.sp
2 -> 20.sp
3 -> 18.sp
4 -> 16.sp
else -> 14.sp
}
val fontWeight = when (level) {
1, 2 -> FontWeight.Bold
3 -> FontWeight.SemiBold
else -> FontWeight.Medium
}
spans.add(Span(
match.range.first,
match.range.last + 1,
SpanStyle(color = headingColor, fontSize = fontSize, fontWeight = fontWeight)
))
}
for (match in keywordPattern.findAll(raw)) {
val key = match.groupValues[1].uppercase()
if (key.startsWith("BEGIN_") || key.startsWith("END_")) continue
val style = if (key == "TITLE") {
SpanStyle(color = headingColor, fontSize = 28.sp, fontWeight = FontWeight.Bold)
} else {
SpanStyle(color = keywordColor)
}
spans.add(Span(match.range.first, match.range.last + 1, style))
}
spans.sortBy { it.start }
for (span in spans) {
builder.addStyle(span.style, span.start, span.end)
}
return TransformedText(builder.toAnnotatedString(), OffsetMapping.Identity)
}
}
package computer.whatthefuck.arcology.app.ui.components
<<orgvisualtransformation-class>>Related Modules
parseLinksfromapp/ui/components/Uses
SearchServicefrom indexer.orgConsumed by screen.org (EditingView)
OrgVisualTransformationdocumented here