1package soy.iko.opencode.ui.components
3import androidx.compose.foundation.background
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.itemsIndexed
11import androidx.compose.foundation.lazy.rememberLazyListState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.Close
16import androidx.compose.material.icons.filled.Search
17import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.OutlinedTextField
21import androidx.compose.material3.Surface
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.LaunchedEffect
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.setValue
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.focus.focusRequester
31import androidx.compose.ui.input.key.Key
32import androidx.compose.ui.input.key.KeyEvent
33import androidx.compose.ui.input.key.KeyEventType
34import androidx.compose.ui.input.key.key
35import androidx.compose.ui.input.key.onPreviewKeyEvent
36import androidx.compose.ui.input.key.type
37import androidx.compose.ui.res.stringResource
38import androidx.compose.ui.semantics.Role
39import androidx.compose.ui.text.input.ImeAction
40import androidx.compose.ui.unit.dp
41import androidx.compose.ui.window.Dialog
42import soy.iko.opencode.R
43import soy.iko.opencode.util.runCatchingCancellable
46 * A keyboard-first command palette: a search field over a list of [actions], filtered as the
47 * user types. Enter runs the first match, tap runs any entry; the field auto-focuses so a
48 * hardware-keyboard user can type immediately after opening it. Dismissed via back/scrim, or
49 * Escape (handled by the opener). Selecting an action dismisses first, then runs it, so a
50 * follow-on dialog isn't immediately covered by the palette.
54 actions: List<PaletteAction>,
55 onDismiss: () -> Unit,
57 var query by remember { mutableStateOf("") }
58 val filtered = remember(query, actions) {
59 if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
61 // Keyboard selection index over `filtered`. Clamped when the list shrinks; Enter/Go run the
62 // selected row (or the first match when nothing is highlighted) — the keyboard-first behavior
63 // a command palette is expected to have.
64 var selectedIndex by remember { mutableStateOf(0) }
65 val listState = rememberLazyListState()
66 LaunchedEffect(filtered.size, query) {
67 // Reset selection whenever the result set changes so the highlight never points past the end.
68 if (selectedIndex > filtered.lastIndex) selectedIndex = filtered.lastIndex.coerceAtLeast(0)
70 LaunchedEffect(selectedIndex) {
71 // Keep the highlighted row visible as the user arrows through the list.
72 if (filtered.isNotEmpty()) listState.animateScrollToItem(selectedIndex)
74 val focus = remember { androidx.compose.ui.focus.FocusRequester() }
75 fun run(action: PaletteAction) {
79 fun moveSelection(delta: Int) {
80 if (filtered.isNotEmpty()) {
81 selectedIndex = (selectedIndex + delta).coerceIn(0, filtered.lastIndex)
84 Dialog(onDismissRequest = onDismiss) {
86 shape = MaterialTheme.shapes.large,
87 tonalElevation = 6.dp,
88 color = MaterialTheme.colorScheme.surface,
90 Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
93 onValueChange = { query = it },
94 placeholder = { Text(stringResource(R.string.palette_hint)) },
96 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
98 if (query.isNotEmpty()) {
99 IconButton(onClick = { query = "" }) {
100 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
105 keyboardActions = KeyboardActions(onGo = {
106 val target = filtered.getOrNull(selectedIndex) ?: filtered.firstOrNull()
107 target?.let { run(it) }
111 .focusRequester(focus)
112 .onPreviewKeyEvent { ev -> handlePaletteKey(ev, filtered, selectedIndex, ::moveSelection, ::run) },
114 LaunchedEffect(Unit) { runCatchingCancellable { focus.requestFocus() } }
115 if (filtered.isEmpty()) {
117 stringResource(R.string.palette_no_results),
118 style = MaterialTheme.typography.bodyMedium,
119 color = MaterialTheme.colorScheme.onSurfaceVariant,
120 modifier = Modifier.padding(16.dp),
123 LazyColumn(state = listState, modifier = Modifier.heightIn(max = 360.dp)) {
124 itemsIndexed(filtered, key = { _, action -> action.id }) { index, action ->
125 val isSelected = index == selectedIndex
128 style = MaterialTheme.typography.bodyLarge,
129 color = if (isSelected) MaterialTheme.colorScheme.primary
130 else MaterialTheme.colorScheme.onSurface,
134 if (isSelected) MaterialTheme.colorScheme.secondaryContainer
135 else androidx.compose.ui.graphics.Color.Transparent,
137 .clickable(role = Role.Button) { run(action) }
138 .padding(horizontal = 12.dp, vertical = 14.dp),
148/** Keyboard navigation for the palette: Enter runs the highlighted (or first) action; arrows/Page
149 * keys move the highlight; Home/End jump to the ends. Returns true when the event is consumed.
150 * Extracted from the composable so the key-handling branches don't inflate its complexity. */
151private fun handlePaletteKey(
153 filtered: List<PaletteAction>,
155 moveSelection: (Int) -> Unit,
156 run: (PaletteAction) -> Unit,
158 if (ev.type != KeyEventType.KeyDown) return false
159 return when (ev.key) {
161 val target = filtered.getOrNull(selectedIndex) ?: filtered.firstOrNull()
165 Key.DirectionDown, Key.PageDown -> { moveSelection(1); true }
166 Key.DirectionUp, Key.PageUp -> { moveSelection(-1); true }
167 Key.MoveHome -> { moveSelection(Int.MIN_VALUE / 2); true }
168 Key.MoveEnd -> { moveSelection(Int.MAX_VALUE / 2); true }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list