The NodePickerScreen provides a consistent full-screen node selection interface used by multiple caller contexts:
Refile target selection (FAB "Refile" or heading long-press in
ReadOnlyView)Link target insertion (
[[formatting toolbar button inEditingView)Potentially any other flow that needs to pick an org-roam node
It shows debounced FTS search results with node title, file path, and outline path. When the query is empty, it shows recent nodes instead.
NEXT Consider refiling NodePickerScreen to its own cluster
It's shared between editor, quiz, and potentially graph/settings screens.
NodePickerScreen
Design: Debounced FTS search
Every keystroke triggers a search through SearchService.searchCombined(). To avoid flooding the database, results are debounced with a 300ms delay via coroutine cancellation. The previous search Job is cancelled before launching a new one.
Design: Recent nodes when query is empty
Rather than showing nothing when the text field is empty, NodePickerScreen fetches SearchService.getRecentNodes(50) minus the current node. This gives the user a starting point of recently accessed nodes without typing anything.
Design: Dialog wrapper, not navigation route
NodePickerScreen is always wrapped in a full-screen Dialog by its caller. It is not registered as a navigation route in Screen.kt. This means it inherits the caller's ViewModel scope and doesn't need its own DI module entry.
arcology.app.ui.screens.NodePickerScreen
package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* A full-screen picker for selecting an org-roam node.
* Shows search results with node title, file path, and outline path.
* Used for refile targets, link targets, and any other node selection flow.
*
* @param searchService Search service for querying nodes
* @param currentNodeId The ID of the current node (to exclude from results)
* @param initialQuery Pre-filled search query (e.g. selected text when inserting a link)
* @param title Dialog title shown in the top app bar
* @param onNodeSelected Callback when user confirms a selection
* @param onDismiss Callback when user cancels
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodePickerScreen(
searchService: SearchService,
currentNodeId: String,
initialQuery: String = "",
title: String = "Select node...",
onNodeSelected: (OrgNode) -> Unit,
onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf(initialQuery) }
var searchResults by remember { mutableStateOf<List<OrgNode>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
var selectedNode by remember { mutableStateOf<OrgNode?>(null) }
var searchJob by remember { mutableStateOf<Job?>(null) }
// Debounced search
LaunchedEffect(query) {
searchJob?.cancel()
if (query.isBlank()) {
// Show recent nodes when query is empty
isSearching = true
searchJob = scope.launch {
try {
val results = searchService.getRecentNodes(50)
.map { it.node }
.filter { it.id != currentNodeId }
searchResults = results
} catch (e: Exception) {
searchResults = emptyList()
} finally {
isSearching = false
}
}
} else {
searchJob = scope.launch {
delay(300) // Debounce
isSearching = true
try {
val results = searchService.searchCombined(query, 20, 50)
.map { it.node }
.filter { it.id != currentNodeId }
searchResults = results
} catch (e: Exception) {
searchResults = emptyList()
} finally {
isSearching = false
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
},
actions = {
IconButton(
onClick = {
selectedNode?.let { onNodeSelected(it) }
},
enabled = selectedNode != null
) {
Icon(Icons.Default.Check, contentDescription = "Confirm")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.imePadding()
.padding(horizontal = 12.dp)
) {
// Search input
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
BasicTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
decorationBox = { innerTextField ->
Box {
if (query.isEmpty()) {
Text(
"Search for target node...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
)
}
innerTextField()
}
}
)
if (query.isNotEmpty()) {
IconButton(
onClick = { query = "" },
modifier = Modifier.size(20.dp)
) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
modifier = Modifier.size(16.dp)
)
}
}
}
}
// Loading indicator
if (isSearching) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
)
}
// Selection info
selectedNode?.let { node ->
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = "Selected:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = node.title ?: "Untitled",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// Empty state
if (searchResults.isEmpty() && !isSearching) {
Text(
text = if (query.isEmpty()) "No recent nodes" else "No results",
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Results list
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(searchResults, key = { it.id }) { node ->
NodePickerRow(
node = node,
isSelected = node.id == selectedNode?.id,
onClick = { selectedNode = node }
)
}
}
}
}
}
@Composable
private fun NodePickerRow(
node: OrgNode,
isSelected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
color = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
// Title with level indicator
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Level indicator
if (node.level > 0) {
Text(
text = "*".repeat(node.level),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
}
Text(
text = node.title ?: "Untitled",
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
}
// File path
Text(
text = node.file.substringAfterLast("/"),
style = MaterialTheme.typography.bodySmall,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Outline path (if available)
if (node.outlinePath.isNotEmpty()) {
Text(
text = node.outlinePath.joinToString(" > "),
style = MaterialTheme.typography.bodySmall,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}Related Modules
Uses
SearchServicefrom indexer.orgUses
OrgNodefrom models.orgCalled from screen.org (OrgDocumentEditorScreen)