Arcology Engine

Node Picker Screen

Contents

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 in EditingView)

  • 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

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

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/NodePickerScreen.kt
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