diff --git a/lib/src/main/java/org/connectbot/terminal/ColorCache.kt b/lib/src/main/java/org/connectbot/terminal/ColorCache.kt index 6a6b0af5..49090650 100644 --- a/lib/src/main/java/org/connectbot/terminal/ColorCache.kt +++ b/lib/src/main/java/org/connectbot/terminal/ColorCache.kt @@ -17,6 +17,7 @@ package org.connectbot.terminal import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb /** * Efficient color cache to prevent allocating Color objects on every frame. @@ -74,6 +75,11 @@ internal object ColorCache { return newColor } + fun ansiPaletteArgb(): IntArray = + IntArray(16) { index -> + paletteCache[index].toArgb() + } + private fun findPaletteIndex(rgb: Int): Int { // Extract components once val r = (rgb shr 16) and 0xFF @@ -110,55 +116,24 @@ internal object ColorCache { } private fun standardAnsiColor(i: Int): Color = when (i) { - 0 -> Color(0, 0, 0) - - // Black - 1 -> Color(205, 0, 0) - - // Red - 2 -> Color(0, 205, 0) - - // Green - 3 -> Color(205, 205, 0) - - // Yellow - 4 -> Color(0, 0, 238) - - // Blue - 5 -> Color(205, 0, 205) - - // Magenta - 6 -> Color(0, 205, 205) - - // Cyan - 7 -> Color(229, 229, 229) - - // White - 8 -> Color(127, 127, 127) - - // Bright Black - 9 -> Color(255, 0, 0) - - // Bright Red - 10 -> Color(0, 255, 0) - - // Bright Green - 11 -> Color(255, 255, 0) - - // Bright Yellow - 12 -> Color(92, 92, 255) - - // Bright Blue - 13 -> Color(255, 0, 255) - - // Bright Magenta - 14 -> Color(0, 255, 255) - - // Bright Cyan - 15 -> Color(255, 255, 255) - - // Bright White - else -> Color.White + 0 -> Color(24, 37, 58) // Black — Dearman surface + 1 -> Color(255, 82, 72) // Red — aggressively not garnet + 2 -> Color(142, 239, 247) // Green slot — Dearman cyan + 3 -> Color(245, 102, 0) // Yellow — Clemson orange + 4 -> Color(45, 125, 255) // Blue — Dearman deep blue + 5 -> Color(82, 45, 128) // Magenta — Clemson purple + 6 -> Color(66, 165, 255) // Cyan — Dearman blue + 7 -> Color(142, 239, 247) // White — Dearman cyan + 8 -> Color(113, 134, 159) // Bright black + 9 -> Color(255, 145, 138) // Bright red — even less garnet + 10 -> Color(173, 247, 251) // Bright green slot — bright Dearman cyan + 11 -> Color(255, 140, 46) // Bright yellow — bright Clemson orange + 12 -> Color(88, 180, 255) // Bright blue + 13 -> Color(151, 105, 201) // Bright magenta — readable Clemson purple + 14 -> Color(173, 247, 251) // Bright cyan + 15 -> Color(247, 251, 255) // Bright white + + else -> Color(247, 251, 255) } private fun rgb6Color(offset: Int): Color { diff --git a/lib/src/main/java/org/connectbot/terminal/SearchController.kt b/lib/src/main/java/org/connectbot/terminal/SearchController.kt new file mode 100644 index 00000000..9b25846a --- /dev/null +++ b/lib/src/main/java/org/connectbot/terminal/SearchController.kt @@ -0,0 +1,15 @@ +package org.connectbot.terminal + +interface SearchController { + val query: String + val matchCount: Int + val activeMatchIndex: Int + + fun find(query: String): Int + + fun next(): Boolean + + fun previous(): Boolean + + fun clear() +} diff --git a/lib/src/main/java/org/connectbot/terminal/SearchManager.kt b/lib/src/main/java/org/connectbot/terminal/SearchManager.kt new file mode 100644 index 00000000..f0d84a27 --- /dev/null +++ b/lib/src/main/java/org/connectbot/terminal/SearchManager.kt @@ -0,0 +1,67 @@ +package org.connectbot.terminal + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +internal class SearchManager { + var query by mutableStateOf("") + private set + + var matches by mutableStateOf>(emptyList()) + private set + + var activeMatchIndex by mutableIntStateOf(-1) + private set + + val matchCount: Int + get() = matches.size + + fun setMatches( + query: String, + matches: List, + ) { + this.query = query + this.matches = matches + activeMatchIndex = if (matches.isNotEmpty()) 0 else -1 + } + + fun next(): SearchMatch? { + if (matches.isEmpty()) { + return null + } + + activeMatchIndex = if (activeMatchIndex >= matches.lastIndex) { + 0 + } else { + activeMatchIndex + 1 + } + + return matches[activeMatchIndex] + } + + fun previous(): SearchMatch? { + if (matches.isEmpty()) { + return null + } + + activeMatchIndex = if (activeMatchIndex <= 0) { + matches.lastIndex + } else { + activeMatchIndex - 1 + } + + return matches[activeMatchIndex] + } + + fun activeMatch(): SearchMatch? { + return matches.getOrNull(activeMatchIndex) + } + + fun clear() { + query = "" + matches = emptyList() + activeMatchIndex = -1 + } +} diff --git a/lib/src/main/java/org/connectbot/terminal/SearchMatch.kt b/lib/src/main/java/org/connectbot/terminal/SearchMatch.kt new file mode 100644 index 00000000..b5195ad5 --- /dev/null +++ b/lib/src/main/java/org/connectbot/terminal/SearchMatch.kt @@ -0,0 +1,6 @@ +package org.connectbot.terminal + +data class SearchMatch( + val range: SelectionRange, + val text: String, +) diff --git a/lib/src/main/java/org/connectbot/terminal/SelectionManager.kt b/lib/src/main/java/org/connectbot/terminal/SelectionManager.kt index b7344cf8..262874d3 100644 --- a/lib/src/main/java/org/connectbot/terminal/SelectionManager.kt +++ b/lib/src/main/java/org/connectbot/terminal/SelectionManager.kt @@ -100,7 +100,7 @@ sealed class SelectionMode { data object LINE : SelectionMode() } -internal data class SelectionRange( +data class SelectionRange( val startRow: Int, val startCol: Int, val endRow: Int, @@ -143,7 +143,6 @@ internal class SelectionManager { private set var selectionRange by mutableStateOf(null) - private set var isSelecting by mutableStateOf(false) private set @@ -239,6 +238,12 @@ internal class SelectionManager { isSelecting = false } + fun applySelectionRange(range: SelectionRange) { + mode = SelectionMode.CHARACTER + isSelecting = false + selectionRange = range + } + fun clearSelection() { mode = SelectionMode.NONE selectionRange = null @@ -376,13 +381,16 @@ internal class SelectionManager { return buildString { for (row in minRow..maxRow) { // Get line from the appropriate source based on scrollback position - val line = if (scrollbackPosition > 0) { - // Viewing scrollback: get from scrollback (stored newest-first, so reverse index) - val scrollbackIndex = snapshot.scrollback.size - scrollbackPosition + row - snapshot.scrollback.getOrNull(scrollbackIndex) + val lineIndex = if (scrollbackPosition > 0) { + snapshot.scrollback.size - scrollbackPosition + row + } else { + snapshot.scrollback.size + row + } + + val line = if (lineIndex < snapshot.scrollback.size) { + snapshot.scrollback.getOrNull(lineIndex) } else { - // Viewing current screen: get from visible lines - snapshot.lines.getOrNull(row) + snapshot.lines.getOrNull(lineIndex - snapshot.scrollback.size) } if (line == null) continue @@ -397,7 +405,13 @@ internal class SelectionManager { } }.trimEnd() append(lineText) - if (row < maxRow && !line.softWrapped) append('\n') + if (row < maxRow) { + if (line.softWrapped) { + append(' ') + } else { + append('\n') + } + } } SelectionMode.CHARACTER, SelectionMode.WORD -> { @@ -419,7 +433,13 @@ internal class SelectionManager { } }.trimEnd() append(lineText) - if (row < maxRow && !line.softWrapped) append('\n') + if (row < maxRow) { + if (line.softWrapped) { + append(' ') + } else { + append('\n') + } + } } SelectionMode.NONE -> {} diff --git a/lib/src/main/java/org/connectbot/terminal/Terminal.kt b/lib/src/main/java/org/connectbot/terminal/Terminal.kt index bbf238d6..56044a7b 100644 --- a/lib/src/main/java/org/connectbot/terminal/Terminal.kt +++ b/lib/src/main/java/org/connectbot/terminal/Terminal.kt @@ -330,6 +330,7 @@ fun Terminal( onSelectionControllerAvailable: ((SelectionController) -> Unit)? = null, onHyperlinkClick: (String) -> Unit = {}, onComposeControllerAvailable: ((ComposeController) -> Unit)? = null, + onSearchControllerAvailable: ((SearchController) -> Unit)? = null, onPasteRequest: (() -> Unit)? = null, rightAltMode: RightAltMode = RightAltMode.CharacterModifier, delKeyMode: DelKeyMode = DelKeyMode.Delete, @@ -359,6 +360,7 @@ fun Terminal( onSelectionControllerAvailable = onSelectionControllerAvailable, onHyperlinkClick = onHyperlinkClick, onComposeControllerAvailable = onComposeControllerAvailable, + onSearchControllerAvailable = onSearchControllerAvailable, onScrollControllerAvailable = null, onPasteRequest = onPasteRequest, onInterceptKey = onInterceptKey, @@ -395,8 +397,10 @@ internal fun TerminalWithAccessibility( forceAccessibilityEnabled: Boolean? = null, onSelectionControllerAvailable: ((SelectionController) -> Unit)? = null, onHyperlinkClick: (String) -> Unit = {}, + onClipboardCopy: ((String) -> Unit)? = null, onComposeControllerAvailable: ((ComposeController) -> Unit)? = null, onScrollControllerAvailable: ((ScrollController) -> Unit)? = null, + onSearchControllerAvailable: ((SearchController) -> Unit)? = null, onPasteRequest: (() -> Unit)? = null, onInterceptKey: ((ComposeKeyEvent) -> Boolean)? = null, rightAltMode: RightAltMode = RightAltMode.CharacterModifier, @@ -590,6 +594,11 @@ internal fun TerminalWithAccessibility( SelectionManager() } + // Search manager + val searchManager = remember(terminalEmulator) { + SearchManager() + } + // Selection controller - expose API for external control val selectionController = remember(terminalEmulator, selectionManager, clipboardManager, screenState) { object : SelectionController { @@ -660,6 +669,62 @@ internal fun TerminalWithAccessibility( } } + // Search controller - expose API for external terminal search + val searchController = remember(terminalEmulator, searchManager, selectionManager, screenState, scrollOffset, scope, baseCharHeight) { + object : SearchController { + override val query: String + get() = searchManager.query + + override val matchCount: Int + get() = searchManager.matchCount + + override val activeMatchIndex: Int + get() = searchManager.activeMatchIndex + + override fun find(query: String): Int { + val matches = screenState.findMatches(query) + searchManager.setMatches(query, matches) + + val activeMatch = searchManager.activeMatch() + if (activeMatch != null) { + applyMatch(activeMatch) + } else { + selectionManager.clearSelection() + } + + return searchManager.matchCount + } + + override fun next(): Boolean { + val match = searchManager.next() ?: return false + applyMatch(match) + return true + } + + override fun previous(): Boolean { + val match = searchManager.previous() ?: return false + applyMatch(match) + return true + } + + override fun clear() { + searchManager.clear() + selectionManager.clearSelection() + } + + private fun applyMatch(match: SearchMatch) { + screenState.scrollToRow(match.range.startRow) + + val visibleRange = match.range.toVisibleRange(screenState) + selectionManager.applySelectionRange(visibleRange) + + scope.launch { + scrollOffset.snapTo(screenState.scrollbackPosition * baseCharHeight) + } + } + } + } + // Compose mode state val composeMode = remember(terminalEmulator) { ComposeMode() @@ -761,6 +826,11 @@ internal fun TerminalWithAccessibility( onScrollControllerAvailable?.invoke(scrollController) } + // Provide search controller to caller + LaunchedEffect(searchController) { + onSearchControllerAvailable?.invoke(searchController) + } + // Sync compose mode active state to ImeInputView so onCreateInputConnection returns the // correct outAttrs (and restartInput is called to apply the change). LaunchedEffect(composeMode.isActive, imeInputView) { @@ -1612,6 +1682,58 @@ internal fun TerminalWithAccessibility( } } +private fun TerminalScreenState.findMatches(query: String): List { + if (query.isBlank()) { + return emptyList() + } + + val matches = mutableListOf() + + for (lineIndex in 0 until totalLines) { + val line = getLine(lineIndex) + val text = line.text + var startIndex = 0 + + while (startIndex < text.length) { + val matchStart = text.indexOf( + string = query, + startIndex = startIndex, + ignoreCase = true, + ) + + if (matchStart < 0) { + break + } + + val matchEnd = matchStart + query.length - 1 + + matches += SearchMatch( + range = SelectionRange( + startRow = lineIndex, + startCol = matchStart, + endRow = lineIndex, + endCol = matchEnd, + ), + text = text, + ) + + startIndex = matchStart + query.length + } + } + + return matches +} + +private fun SelectionRange.toVisibleRange(screenState: TerminalScreenState): SelectionRange { + val visibleStartRow = startRow - (screenState.snapshot.scrollback.size - screenState.scrollbackPosition) + val visibleEndRow = endRow - (screenState.snapshot.scrollback.size - screenState.scrollbackPosition) + + return copy( + startRow = visibleStartRow, + endRow = visibleEndRow, + ) +} + /** * Draw a single terminal line. */ diff --git a/lib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt b/lib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt index 0f4275e1..16e1e58b 100644 --- a/lib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt +++ b/lib/src/main/java/org/connectbot/terminal/TerminalEmulator.kt @@ -24,6 +24,7 @@ import android.util.Log import android.view.Choreographer import androidx.annotation.VisibleForTesting import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -363,6 +364,23 @@ internal class TerminalEmulatorImpl( private val terminalNative by lazy { TerminalNative(this).apply { resize(initialRows, initialCols) + + val ansiPaletteResult = setPaletteColors( + ColorCache.ansiPaletteArgb(), + 16, + ) + if (ansiPaletteResult != 16) { + Log.e(TAG, "Failed to set initial terminal ANSI palette") + } + + val defaultColorResult = setDefaultColors( + currentDefaultForeground.toArgb(), + currentDefaultBackground.toArgb(), + ) + if (defaultColorResult != 0) { + Log.e(TAG, "Failed to set initial terminal default colors") + } + if (setBoldHighbright(boldAsBright) != 0) { Log.e(TAG, "Failed to set boldAsBright=$boldAsBright") } @@ -398,37 +416,27 @@ internal class TerminalEmulatorImpl( cols = newCols terminalNative.resize(newRows, newCols) - // Capture current default colors (thread-safe) - val currentDefaultFg: Color - val currentDefaultBg: Color synchronized(damageLock) { - currentDefaultFg = currentDefaultForeground - currentDefaultBg = currentDefaultBackground - } + val currentDefaultFg = currentDefaultForeground + val currentDefaultBg = currentDefaultBackground - // Resize currentLines to match new dimensions, preserving semantic segments - synchronized(damageLock) { - val oldLines = currentLines currentLines = List(newRows) { row -> - if (row < oldLines.size) { - // Preserve semantic segments from the old line - TerminalLine.empty(row, newCols, currentDefaultFg, currentDefaultBg) - .copy(semanticSegments = oldLines[row].semanticSegments) - } else { - TerminalLine.empty(row, newCols, currentDefaultFg, currentDefaultBg) - } + TerminalLine.empty(row, newCols, currentDefaultFg, currentDefaultBg) } - if (newRows < oldLines.size) { - for (row in newRows until oldLines.size) { - removeStoredSegmentTexts(row) - } - } - } - // Rebuild all lines after resize - invalidateDisplay() + pendingDamageRegions.clear() + pendingDamageRegions.add( + DamageRegion( + startRow = 0, + endRow = newRows, + startCol = 0, + endCol = newCols, + preserveSegments = false, + ), + ) + requestProcessPendingUpdatesLocked() + } - // Resize callback - post to handler to avoid blocking native thread handler.post { onResize?.invoke(TerminalDimensions(rows = rows, columns = cols)) } diff --git a/lib/src/main/java/org/connectbot/terminal/TerminalScreenState.kt b/lib/src/main/java/org/connectbot/terminal/TerminalScreenState.kt index 25b86a56..40e1554c 100644 --- a/lib/src/main/java/org/connectbot/terminal/TerminalScreenState.kt +++ b/lib/src/main/java/org/connectbot/terminal/TerminalScreenState.kt @@ -330,6 +330,11 @@ internal class TerminalScreenState( scrollbackPosition = (scrollbackPosition + delta).coerceIn(0, snapshot.scrollback.size) } + fun scrollToRow(row: Int) { + val targetPosition = (snapshot.scrollback.size - row).coerceIn(0, snapshot.scrollback.size) + scrollbackPosition = targetPosition + } + /** * Check if currently scrolled to the bottom. */