diff --git a/CHANGELOG.md b/CHANGELOG.md index 906d05f..0bdff2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ What changed in each version of Cortex Terminal, newest first. The release workf of a GitHub release from the section of its version here. Older versions are described on the [GitHub releases](https://github.com/ssfuisu/cortex/releases) page. +## 1.26.45 (2026-10-28) + +### Fixed + +- **The Select all, Copy, Cut and Paste buttons of a keyboard did nothing (#32).** A keyboard asks the + app for them as the system's editing actions (or sends the Copy, Cut and Paste keys), and the + terminal answered none of them: only its own bar, shown after a long press, worked. They now select + all the text of the terminal, copy the selection, and paste, and the terminal tells a keyboard that + copies by itself what is selected. +- **Delete over a selection** only took the selection away and deleted a single character (#32). With + the cursor in the selection (all of it selected, say), delete now clears the command line you are + typing. The text above it belongs to the programs and stays. Not in a full-screen program (vim, + nano), where the keys that clear the line mean other things. + ## 1.26.44 (2026-10-27) ### Added diff --git a/README.md b/README.md index 9558073..0970e03 100644 --- a/README.md +++ b/README.md @@ -334,10 +334,6 @@ Have feedback, questions, or ideas for new features? We'd love to hear from you: - **GitHub Issues**: Open a bug report or feature request on [GitHub Issues](https://github.com/ssfuisu/cortex/issues). - **Telegram**: Chat directly with the developer on Telegram: [@ratzgn](https://t.me/ratzgn). -## Thanks - -The app icon is the design of [hasanelfalakiy](https://github.com/hasanelfalakiy), who suggested it in [#30](https://github.com/ssfuisu/cortex/issues/30). Thank you! - --- ## License diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c7bad3..f2b7bba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { // from the app's writable data directory (filesDir). Android 10+ (targetSdk >= 29) // enforces SELinux W^X restrictions preventing execution from writable app data storage. targetSdk = 28 - versionCode = 12644 - versionName = "1.26.44" + versionCode = 12645 + versionName = "1.26.45" // UbuntuOnDeviceTest (app/src/androidTest), on Firebase Test Lab testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt index 40ea6a3..8340815 100644 --- a/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt +++ b/app/src/main/java/org/cortex/terminal/runtime/BootstrapManager.kt @@ -12,7 +12,7 @@ import java.util.zip.GZIPInputStream object BootstrapManager { private const val TAG = "BootstrapManager" - const val CURRENT_BOOTSTRAP_VERSION = 12644 + const val CURRENT_BOOTSTRAP_VERSION = 12645 fun initializeFileSystem(context: Context) { val root = Environment.getCortexRoot(context) diff --git a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt index 5684a30..1f9f990 100644 --- a/app/src/main/java/org/cortex/terminal/view/TerminalView.kt +++ b/app/src/main/java/org/cortex/terminal/view/TerminalView.kt @@ -66,6 +66,8 @@ class TerminalView @JvmOverloads constructor( value?.onRedraw = { postInvalidate() } value?.emulator?.onScreenUpdate = onEmulatorScreenUpdate if (previous !== value) { + // The selection is the old session's: delete over it would act on the new one's line + clearSelection() previous?.setFocused(false) value?.setFocused(hasWindowFocus()) cursorBlinkOn = true @@ -1356,20 +1358,30 @@ class TerminalView @JvmOverloads constructor( } fun copySelectionToClipboard(): Boolean { - val norm = getNormalizedSelection() - val text = session?.emulator?.buffer?.getSelectedText(norm[0], norm[1], norm[2], norm[3]) ?: "" - if (text.isNotEmpty()) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Cortex", text)) - Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - clearSelection() - return true - } + // Without a selection there is nothing to copy (the numbers are those of the last one) + if (!isSelecting) return false + val text = selectedTerminalText() + if (text != null) putOnClipboard(text) clearSelection() - return false + return text != null } - fun pasteFromClipboard() { + /** Cut: copied, then what delete does to the selection (the command line, when the cursor is in it). */ + private fun cutSelectionToClipboard(): Boolean { + val text = selectedTerminalText() + if (text != null) putOnClipboard(text) + deleteSelection() + return text != null + } + + private fun putOnClipboard(text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Cortex", text)) + Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + + /** [plainTextOnly]: the clip's text, not the image it may also hold (the keyboard's Paste as plain text). */ + fun pasteFromClipboard(plainTextOnly: Boolean = false) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return val clip = clipboard.primaryClip ?: return if (clip.itemCount == 0) return @@ -1386,7 +1398,8 @@ class TerminalView @JvmOverloads constructor( p.endsWith(".webp", true) || p.endsWith(".gif", true) } == true) - if (uri != null && hasImageMime) { + val preferText = plainTextOnly && !item.text.isNullOrEmpty() + if (!preferText && uri != null && hasImageMime) { val savedPath = saveImageFromUri(uri, mimeType) if (savedPath != null) { session?.write(savedPath) @@ -1503,6 +1516,7 @@ class TerminalView @JvmOverloads constructor( fun selectAllText() { val histSize = session?.emulator?.buffer?.history?.size ?: 0 + isSelecting = true // from the keyboard's Select all as well, not only from the bar of a selection selectStartRow = -histSize selectStartCol = 0 selectEndRow = rows - 1 @@ -1513,6 +1527,44 @@ class TerminalView @JvmOverloads constructor( } } + /** The text of the selection, for a keyboard that copies it itself; null without one. */ + private fun selectedTerminalText(): String? { + if (!isSelecting) return null + val norm = getNormalizedSelection() + val emulator = session?.emulator ?: return null + // Under the lock: output on the PTY thread moves the rows of the screen and the scrollback + val text = emulator.lock.withLock { + emulator.buffer.getSelectedText(norm[0], norm[1], norm[2], norm[3]) + } + return text.takeIf { it.isNotEmpty() } + } + + /** + * What delete does to a selection (#32). The text on the screen is the programs', so what can be + * taken away is what is being typed: with the cursor in the selection (all of it selected, say), the + * command line is cleared, with End and then the kill of the line that shells and the tty know + * (Ctrl+E, Ctrl+U). Not in a full-screen program, where those keys mean other things (in nano + * Ctrl+U pastes). The selection is gone after it either way; true when the line was cleared. + */ + private fun deleteSelection(): Boolean { + if (!isSelecting) return false + val emulator = session?.emulator + var clear = false + if (emulator != null) { + emulator.lock.withLock { + val buffer = emulator.buffer + clear = !buffer.isAlternate && + selectionHoldsCell(getNormalizedSelection(), buffer.cursorRow, buffer.cursorCol) + } + } + clearSelection() + if (clear) { + scrollOffset = 0 + session?.write(CLEAR_LINE) + } + return clear + } + /** * Search state can be mutated from the PTY reader thread (throttled live refresh), * so always hand the result back to the UI thread before touching widgets. @@ -1692,7 +1744,26 @@ class TerminalView @JvmOverloads constructor( override fun getTextAfterCursor(n: Int, flags: Int): CharSequence = "" - override fun getSelectedText(flags: Int): CharSequence? = null + override fun getSelectedText(flags: Int): CharSequence? = selectedTerminalText() + + /** The keyboard's own Select all, Copy, Cut and Paste buttons (the system's names for them). */ + override fun performContextMenuAction(id: Int): Boolean = when (id) { + android.R.id.selectAll -> { + selectAllText() + true + } + android.R.id.copy -> copySelectionToClipboard() + android.R.id.cut -> cutSelectionToClipboard() + android.R.id.paste -> { + pasteFromClipboard() + true + } + android.R.id.pasteAsPlainText -> { + pasteFromClipboard(plainTextOnly = true) + true + } + else -> super.performContextMenuAction(id) + } @RequiresApi(Build.VERSION_CODES.S) override fun getSurroundingText(beforeLength: Int, afterLength: Int, flags: Int): SurroundingText { @@ -1749,6 +1820,11 @@ class TerminalView @JvmOverloads constructor( } override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + // Nothing committed over a selection is how a keyboard deletes it + if (text.isNullOrEmpty() && composingLength == 0 && deleteSelection()) { + lineIsBack() + return true + } clearSelection() // What the keyboard was composing (the pinyin of a Chinese word, the kana of a Japanese // one) is on the line already: the text it commits replaces it @@ -1799,7 +1875,11 @@ class TerminalView @JvmOverloads constructor( } override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { - clearSelection() + if (deleteSelection()) { + composingLength = 0 // the line it was composed on is gone + lineIsBack() + return true + } if (beforeLength == 0 && afterLength == 0) { sendKeySequence(KeyEvent.KEYCODE_DEL) } else { @@ -1822,7 +1902,6 @@ class TerminalView @JvmOverloads constructor( override fun sendKeyEvent(event: KeyEvent): Boolean { if (event.action == KeyEvent.ACTION_DOWN) { - clearSelection() val handled = onKeyDown(event.keyCode, event) if (event.keyCode == KeyEvent.KEYCODE_DEL || event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) lineIsBack() return handled @@ -1934,6 +2013,22 @@ class TerminalView @JvmOverloads constructor( } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // The keys of a keyboard's clipboard buttons, and delete over a selection + when (keyCode) { + KeyEvent.KEYCODE_PASTE -> { + pasteFromClipboard() + return true + } + KeyEvent.KEYCODE_COPY -> { + copySelectionToClipboard() + return true + } + KeyEvent.KEYCODE_CUT -> { + cutSelectionToClipboard() + return true + } + KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_FORWARD_DEL -> if (deleteSelection()) return true + } clearSelection() scrollOffset = 0 if (sendKeySequence(keyCode)) { @@ -1992,6 +2087,17 @@ private const val IME_LINE = " " internal fun selectionBackground(dark: Boolean, background: Int, accent: Int): Int = if (dark) Chrome.WHITE else Chrome.mix(background, accent, 0.35f) +/** What clears the command line: End, then the kill of the line (the tty's and readline's Ctrl+U). */ +private const val CLEAR_LINE = "\u0005\u0015" + +/** + * Whether the cell ([row], [col]) is in the selection [norm]: its first row and column, then its last + * ones, in this order (the buffer's rows: those of the scrollback are negative). + */ +internal fun selectionHoldsCell(norm: IntArray, row: Int, col: Int): Boolean = + (row > norm[0] || (row == norm[0] && col >= norm[1])) && + (row < norm[2] || (row == norm[2] && col <= norm[3])) + /** The text sizes two fingers can set, in sp. */ internal const val MIN_PINCH_TEXT_SP = 6f internal const val MAX_PINCH_TEXT_SP = 36f diff --git a/app/src/test/java/org/cortex/terminal/view/ChromeViewsTest.kt b/app/src/test/java/org/cortex/terminal/view/ChromeViewsTest.kt index 12cd3a0..fa70666 100644 --- a/app/src/test/java/org/cortex/terminal/view/ChromeViewsTest.kt +++ b/app/src/test/java/org/cortex/terminal/view/ChromeViewsTest.kt @@ -3,6 +3,7 @@ package org.cortex.terminal.view import android.app.Activity import android.content.DialogInterface import android.os.Looper +import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -222,4 +223,70 @@ class ChromeViewsTest { assertEquals(" ", around.text.toString()) assertEquals(1, around.selectionStart) } + + @Test + fun theKeyboardsOwnSelectAllCopyAndPasteButtonsAreAnswered() { + // WM Keyboard's buttons did nothing (#32): they come as the system's context menu actions + val view = TerminalView(activity) + val ic = view.onCreateInputConnection(EditorInfo()) + assertFalse("nothing to copy without a selection", ic.performContextMenuAction(android.R.id.copy)) + assertFalse(view.isSelecting) + assertTrue(ic.performContextMenuAction(android.R.id.selectAll)) + assertTrue("Select all selects", view.isSelecting) + assertTrue(ic.performContextMenuAction(android.R.id.paste)) + assertTrue(ic.performContextMenuAction(android.R.id.pasteAsPlainText)) + assertFalse("another action is not ours", ic.performContextMenuAction(android.R.id.startSelectingText)) + // Cut is a copy and then what delete does: the selection is gone (nothing on screen to copy without a session) + view.selectAllText() + assertFalse(ic.performContextMenuAction(android.R.id.cut)) + assertFalse(view.isSelecting) + } + + @Test + fun theClipboardKeysCopyAndPaste() { + val view = TerminalView(activity) + view.selectAllText() + assertTrue(view.isSelecting) + assertTrue(view.onKeyDown(KeyEvent.KEYCODE_COPY, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_COPY))) + assertFalse("copy leaves the selection (nothing on screen to copy without a session)", view.isSelecting) + assertTrue(view.onKeyDown(KeyEvent.KEYCODE_PASTE, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_PASTE))) + view.selectAllText() + assertTrue(view.onKeyDown(KeyEvent.KEYCODE_CUT, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_CUT))) + assertFalse(view.isSelecting) + } + + @Test + fun deleteOverASelectionTakesTheSelectionAway() { + val view = TerminalView(activity) + val ic = view.onCreateInputConnection(EditorInfo()) + view.selectAllText() + assertTrue(ic.deleteSurroundingText(1, 0)) + assertFalse(view.isSelecting) + view.selectAllText() + assertTrue(ic.commitText("", 1)) + assertFalse(view.isSelecting) + view.selectAllText() + // (Without a session there is nothing to send the key to: what it returns is not the point) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + assertFalse(view.isSelecting) + } + + @Test + fun theCursorIsInASelectionOnItsRowsAndBetweenItsEnds() { + // From row 2, column 5 to row 4, column 3 (rows of the scrollback are negative) + val norm = intArrayOf(2, 5, 4, 3) + assertTrue(selectionHoldsCell(norm, 3, 0)) + assertTrue(selectionHoldsCell(norm, 3, 79)) + assertTrue(selectionHoldsCell(norm, 2, 5)) + assertFalse(selectionHoldsCell(norm, 2, 4)) + assertTrue(selectionHoldsCell(norm, 4, 3)) + assertFalse(selectionHoldsCell(norm, 4, 4)) + assertFalse(selectionHoldsCell(norm, 1, 40)) + assertFalse(selectionHoldsCell(norm, 5, 0)) + // All of it, as Select all does: from the first row of the scrollback to the last of the screen + assertTrue(selectionHoldsCell(intArrayOf(-300, 0, 23, 79), 12, 6)) + // One cell + assertTrue(selectionHoldsCell(intArrayOf(7, 2, 7, 2), 7, 2)) + assertFalse(selectionHoldsCell(intArrayOf(7, 2, 7, 2), 7, 3)) + } }