# Conflicts: # android/scripts/icons.txt
Showing 19 changed files with 5201 additions and 4 deletions.
| @@ -0,0 +1,94 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** Keys that send escape sequences rather than text. */ | |
| 4 | + enum class TermKey { | |
| 5 | + Enter, Tab, Escape, Backspace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, | |
| 6 | + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, | |
| 7 | + } | |
| 8 | + | |
| 9 | + /** | |
| 10 | + * What a key sends to the shell, as xterm encodes it: the desktop's | |
| 11 | + * `key_bytes` (src/terminal.rs). Cursor keys follow DECCKM ([appCursor]). | |
| 12 | + */ | |
| 13 | + fun keyBytes(key: TermKey, appCursor: Boolean, ctrl: Boolean = false, alt: Boolean = false, shift: Boolean = false): ByteArray { | |
| 14 | + // xterm's modifier parameter: 1 + Shift + 2·Alt + 4·Ctrl. | |
| 15 | + val param = 1 + (if (shift) 1 else 0) + (if (alt) 2 else 0) + (if (ctrl) 4 else 0) | |
| 16 | + fun escaped(text: String) = ((if (alt) "\u001b" else "") + text).toByteArray() | |
| 17 | + fun cursor(letter: Char) = when { | |
| 18 | + param == 1 && appCursor -> "\u001bO$letter" | |
| 19 | + param == 1 -> "\u001b[$letter" | |
| 20 | + else -> "\u001b[1;$param$letter" | |
| 21 | + }.toByteArray() | |
| 22 | + fun tilde(code: Int) = (if (param == 1) "\u001b[$code~" else "\u001b[$code;$param~").toByteArray() | |
| 23 | + return when (key) { | |
| 24 | + TermKey.Enter -> escaped("\r") | |
| 25 | + TermKey.Tab -> if (shift) "\u001b[Z".toByteArray() else escaped("\t") | |
| 26 | + TermKey.Escape -> escaped("\u001b") | |
| 27 | + TermKey.Backspace -> if (ctrl) escaped("\u0008") else escaped("\u007f") | |
| 28 | + TermKey.Up -> cursor('A') | |
| 29 | + TermKey.Down -> cursor('B') | |
| 30 | + TermKey.Right -> cursor('C') | |
| 31 | + TermKey.Left -> cursor('D') | |
| 32 | + TermKey.Home -> cursor('H') | |
| 33 | + TermKey.End -> cursor('F') | |
| 34 | + TermKey.Insert -> tilde(2) | |
| 35 | + TermKey.Delete -> tilde(3) | |
| 36 | + TermKey.PageUp -> tilde(5) | |
| 37 | + TermKey.PageDown -> tilde(6) | |
| 38 | + TermKey.F1, TermKey.F2, TermKey.F3, TermKey.F4 -> { | |
| 39 | + val letter = 'P' + (key.ordinal - TermKey.F1.ordinal) | |
| 40 | + (if (param == 1) "\u001bO$letter" else "\u001b[1;$param$letter").toByteArray() | |
| 41 | + } | |
| 42 | + TermKey.F5 -> tilde(15) | |
| 43 | + TermKey.F6 -> tilde(17) | |
| 44 | + TermKey.F7 -> tilde(18) | |
| 45 | + TermKey.F8 -> tilde(19) | |
| 46 | + TermKey.F9 -> tilde(20) | |
| 47 | + TermKey.F10 -> tilde(21) | |
| 48 | + TermKey.F11 -> tilde(23) | |
| 49 | + TermKey.F12 -> tilde(24) | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * Typed text, with Ctrl and Alt applied to it as the desktop applies them | |
| 55 | + * to a key: Ctrl turns a letter into its control code, Alt puts ESC first. | |
| 56 | + */ | |
| 57 | + fun textBytes(text: String, ctrl: Boolean = false, alt: Boolean = false): ByteArray { | |
| 58 | + if (text.isEmpty()) return ByteArray(0) | |
| 59 | + var body = text.replace("\r\n", "\r").replace('\n', '\r') | |
| 60 | + if (ctrl) { | |
| 61 | + val first = body.codePointAt(0) | |
| 62 | + val control = controlCode(first) | |
| 63 | + if (control != null) body = String(Character.toChars(control)) + body.substring(Character.charCount(first)) | |
| 64 | + } | |
| 65 | + return ((if (alt) "\u001b" else "") + body).toByteArray() | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** The control code Ctrl and a character make, as the desktop's table has them. */ | |
| 69 | + fun controlCode(c: Int): Int? { | |
| 70 | + val lower = if (c in 'A'.code..'Z'.code) c + 32 else c | |
| 71 | + return when (lower) { | |
| 72 | + in 'a'.code..'z'.code -> lower - 'a'.code + 1 | |
| 73 | + ' '.code, '@'.code, '2'.code -> 0 | |
| 74 | + '['.code, '3'.code -> 0x1b | |
| 75 | + '\\'.code, '4'.code -> 0x1c | |
| 76 | + ']'.code, '5'.code -> 0x1d | |
| 77 | + '^'.code, '6'.code -> 0x1e | |
| 78 | + '_'.code, '-'.code, '/'.code, '7'.code -> 0x1f | |
| 79 | + '?'.code, '8'.code -> 0x7f | |
| 80 | + else -> null | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * What a paste sends: line breaks as Enter, and inside bracketed paste | |
| 86 | + * markers when the program asked for them (with any markers in the text | |
| 87 | + * taken out, so a paste can't end itself early). | |
| 88 | + */ | |
| 89 | + fun pasteBytes(text: String, bracketed: Boolean): ByteArray = | |
| 90 | + if (bracketed) { | |
| 91 | + ("\u001b[200~" + text.replace("\u001b", "") + "\u001b[201~").toByteArray() | |
| 92 | + } else { | |
| 93 | + text.replace("\r\n", "\r").replace('\n', '\r').toByteArray() | |
| 94 | + } | |
| @@ -0,0 +1,105 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** | |
| 4 | + * A cell's color: [DEFAULT], one of the 256 indexed colors, or 24-bit RGB, | |
| 5 | + * packed in an `Int` so a line's colors are plain arrays. | |
| 6 | + */ | |
| 7 | + object TermColor { | |
| 8 | + const val DEFAULT = 0 | |
| 9 | + private const val INDEXED = 1 shl 24 | |
| 10 | + private const val RGB = 2 shl 24 | |
| 11 | + | |
| 12 | + fun indexed(index: Int) = INDEXED or (index and 0xff) | |
| 13 | + | |
| 14 | + fun rgb(r: Int, g: Int, b: Int) = RGB or ((r and 0xff) shl 16) or ((g and 0xff) shl 8) or (b and 0xff) | |
| 15 | + | |
| 16 | + fun isIndexed(color: Int) = color ushr 24 == 1 | |
| 17 | + | |
| 18 | + fun isRgb(color: Int) = color ushr 24 == 2 | |
| 19 | + | |
| 20 | + /** The index of an indexed color, or the `0xrrggbb` of an RGB one. */ | |
| 21 | + fun value(color: Int) = color and 0xffffff | |
| 22 | + } | |
| 23 | + | |
| 24 | + /** A cell's attributes, as bits. */ | |
| 25 | + object Attr { | |
| 26 | + const val BOLD = 1 | |
| 27 | + const val DIM = 2 | |
| 28 | + const val ITALIC = 4 | |
| 29 | + const val UNDERLINE = 8 | |
| 30 | + const val INVERSE = 16 | |
| 31 | + const val HIDDEN = 32 | |
| 32 | + const val STRIKE = 64 | |
| 33 | + | |
| 34 | + /** The first half of a double-width character. */ | |
| 35 | + const val WIDE = 128 | |
| 36 | + | |
| 37 | + /** The second half of a double-width character: nothing to draw. */ | |
| 38 | + const val SPACER = 256 | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** One row of cells: code points (0 for blank), colors and attributes. */ | |
| 42 | + class Line(columns: Int) { | |
| 43 | + var chars = IntArray(columns) | |
| 44 | + private set | |
| 45 | + var fg = IntArray(columns) | |
| 46 | + private set | |
| 47 | + var bg = IntArray(columns) | |
| 48 | + private set | |
| 49 | + var attrs = IntArray(columns) | |
| 50 | + private set | |
| 51 | + | |
| 52 | + /** The text goes on in the next line: it wrapped here rather than broke. */ | |
| 53 | + var wrapped = false | |
| 54 | + | |
| 55 | + val columns: Int get() = chars.size | |
| 56 | + | |
| 57 | + fun resize(columns: Int) { | |
| 58 | + val old = chars.size | |
| 59 | + if (columns == old) return | |
| 60 | + chars = chars.copyOf(columns) | |
| 61 | + fg = fg.copyOf(columns) | |
| 62 | + bg = bg.copyOf(columns) | |
| 63 | + attrs = attrs.copyOf(columns) | |
| 64 | + // A wide character cut in half by the new edge goes. | |
| 65 | + if (columns > 0 && attrs[columns - 1] and Attr.WIDE != 0) { | |
| 66 | + chars[columns - 1] = 0 | |
| 67 | + attrs[columns - 1] = 0 | |
| 68 | + } | |
| 69 | + if (columns < old) wrapped = false | |
| 70 | + } | |
| 71 | + | |
| 72 | + /** Blanks `from until to` in background [background]. */ | |
| 73 | + fun clear(from: Int, to: Int, background: Int) { | |
| 74 | + val end = to.coerceAtMost(chars.size) | |
| 75 | + for (i in from.coerceAtLeast(0) until end) { | |
| 76 | + chars[i] = 0 | |
| 77 | + fg[i] = TermColor.DEFAULT | |
| 78 | + bg[i] = background | |
| 79 | + attrs[i] = 0 | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + fun copyCell(from: Int, to: Int) { | |
| 84 | + chars[to] = chars[from] | |
| 85 | + fg[to] = fg[from] | |
| 86 | + bg[to] = bg[from] | |
| 87 | + attrs[to] = attrs[from] | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** The row's text, blanks as spaces; trailing blanks trimmed when asked. */ | |
| 91 | + fun text(trim: Boolean = true): String { | |
| 92 | + val builder = StringBuilder(chars.size) | |
| 93 | + for (i in chars.indices) { | |
| 94 | + if (attrs[i] and Attr.SPACER != 0) continue | |
| 95 | + val c = chars[i] | |
| 96 | + if (c == 0) builder.append(' ') else builder.appendCodePoint(c) | |
| 97 | + } | |
| 98 | + return if (trim) builder.trimEnd().toString() else builder.toString() | |
| 99 | + } | |
| 100 | + | |
| 101 | + fun isBlank(): Boolean { | |
| 102 | + for (i in chars.indices) if (chars[i] != 0 || bg[i] != TermColor.DEFAULT) return false | |
| 103 | + return true | |
| 104 | + } | |
| 105 | + } | |
| @@ -0,0 +1,975 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** How the cursor is drawn, as `DECSCUSR` sets it. */ | |
| 4 | + enum class CursorShape { Block, Underline, Beam } | |
| 5 | + | |
| 6 | + /** | |
| 7 | + * A compact VT/xterm emulator: the screen a shell draws on. It parses what | |
| 8 | + * the shell prints (UTF-8, C0 controls, ESC, CSI, OSC and DCS sequences) | |
| 9 | + * into a grid of cells with an alternate screen and a scrollback, and | |
| 10 | + * answers the queries a shell asks (cursor position, device attributes, | |
| 11 | + * colors) through [respond]. The desktop does this with | |
| 12 | + * `alacritty_terminal`; this keeps to what shells and full-screen programs | |
| 13 | + * use. | |
| 14 | + * | |
| 15 | + * Not thread-safe: feed, resize and read it on one thread. | |
| 16 | + */ | |
| 17 | + class Terminal( | |
| 18 | + columns: Int, | |
| 19 | + rows: Int, | |
| 20 | + private val scrollbackLimit: Int = SCROLLBACK, | |
| 21 | + /** Bytes the terminal sends back to the shell, as answers to queries. */ | |
| 22 | + private val respond: (ByteArray) -> Unit = {}, | |
| 23 | + /** The `0xrrggbb` of an indexed color (256 is the foreground, 257 the background, 258 the cursor), for color queries. */ | |
| 24 | + private val colorOf: (Int) -> Int? = { null }, | |
| 25 | + ) { | |
| 26 | + var columns = columns.coerceAtLeast(1) | |
| 27 | + private set | |
| 28 | + var rows = rows.coerceAtLeast(1) | |
| 29 | + private set | |
| 30 | + | |
| 31 | + private var main = ArrayList<Line>() | |
| 32 | + private var alternate = ArrayList<Line>() | |
| 33 | + | |
| 34 | + /** The rows on screen, top to bottom. */ | |
| 35 | + private var screen = main | |
| 36 | + | |
| 37 | + /** Lines scrolled off the top of the main screen, oldest first. */ | |
| 38 | + private val history = ArrayDeque<Line>() | |
| 39 | + | |
| 40 | + /** The alternate screen shows: full-screen programs draw there. */ | |
| 41 | + var altScreen = false | |
| 42 | + private set | |
| 43 | + | |
| 44 | + // ---- cursor and pen ---- | |
| 45 | + | |
| 46 | + var cursorRow = 0 | |
| 47 | + private set | |
| 48 | + var cursorColumn = 0 | |
| 49 | + private set | |
| 50 | + | |
| 51 | + /** A character went into the last column; the next one wraps first. */ | |
| 52 | + private var pendingWrap = false | |
| 53 | + private var fg = TermColor.DEFAULT | |
| 54 | + private var bg = TermColor.DEFAULT | |
| 55 | + private var attrs = 0 | |
| 56 | + private var lastPrinted = ' '.code | |
| 57 | + | |
| 58 | + private class Saved( | |
| 59 | + val row: Int, | |
| 60 | + val column: Int, | |
| 61 | + val fg: Int, | |
| 62 | + val bg: Int, | |
| 63 | + val attrs: Int, | |
| 64 | + val origin: Boolean, | |
| 65 | + val g0: Boolean, | |
| 66 | + val g1: Boolean, | |
| 67 | + val shifted: Boolean, | |
| 68 | + ) | |
| 69 | + | |
| 70 | + private var savedMain: Saved? = null | |
| 71 | + private var savedAlternate: Saved? = null | |
| 72 | + | |
| 73 | + // ---- modes ---- | |
| 74 | + | |
| 75 | + /** DECCKM: cursor keys send `ESC O A` rather than `ESC [ A`. */ | |
| 76 | + var applicationCursor = false | |
| 77 | + private set | |
| 78 | + | |
| 79 | + /** The program wants pastes between `ESC [200~` and `ESC [201~`. */ | |
| 80 | + var bracketedPaste = false | |
| 81 | + private set | |
| 82 | + var cursorVisible = true | |
| 83 | + private set | |
| 84 | + var cursorShape = CursorShape.Block | |
| 85 | + private set | |
| 86 | + | |
| 87 | + /** Mouse reporting the program asked for: 0 none, else 1000, 1002 or 1003. */ | |
| 88 | + var mouseTracking = 0 | |
| 89 | + private set | |
| 90 | + | |
| 91 | + /** Mouse reports in SGR form (`?1006`). */ | |
| 92 | + var sgrMouse = false | |
| 93 | + private set | |
| 94 | + | |
| 95 | + /** The wheel sends arrow keys on the alternate screen (`?1007`, on by default). */ | |
| 96 | + var alternateScroll = true | |
| 97 | + private set | |
| 98 | + private var autoWrap = true | |
| 99 | + private var originMode = false | |
| 100 | + private var insertMode = false | |
| 101 | + private var newLineMode = false | |
| 102 | + | |
| 103 | + private var scrollTop = 0 | |
| 104 | + private var scrollBottom = this.rows - 1 | |
| 105 | + private var tabStops = BooleanArray(this.columns) | |
| 106 | + | |
| 107 | + /** G0 and G1 are DEC line drawing rather than ASCII, and which one is shifted in. */ | |
| 108 | + private var g0Graphics = false | |
| 109 | + private var g1Graphics = false | |
| 110 | + private var shiftedOut = false | |
| 111 | + | |
| 112 | + /** How many lines up the view is scrolled into the scrollback; 0 follows the shell. */ | |
| 113 | + var displayOffset = 0 | |
| 114 | + private set | |
| 115 | + | |
| 116 | + // ---- parser ---- | |
| 117 | + | |
| 118 | + private enum class State { Ground, Escape, EscapeIntermediate, Csi, Osc, Ignore, IgnoreEscape, Charset } | |
| 119 | + | |
| 120 | + private val decoder = Utf8Decoder() | |
| 121 | + private var state = State.Ground | |
| 122 | + private val params = IntArray(MAX_PARAMS) | |
| 123 | + | |
| 124 | + /** Whether a parameter is a colon subparameter of the one before it. */ | |
| 125 | + private val colon = BooleanArray(MAX_PARAMS) | |
| 126 | + private var paramCount = 0 | |
| 127 | + private var paramStarted = false | |
| 128 | + private var privateMarker = 0 | |
| 129 | + private var intermediate = 0 | |
| 130 | + private var charsetTarget = 0 | |
| 131 | + private val osc = StringBuilder() | |
| 132 | + private var oscEscape = false | |
| 133 | + | |
| 134 | + init { | |
| 135 | + repeat(this.rows) { | |
| 136 | + main.add(Line(this.columns)) | |
| 137 | + alternate.add(Line(this.columns)) | |
| 138 | + } | |
| 139 | + resetTabs() | |
| 140 | + } | |
| 141 | + | |
| 142 | + val historySize: Int get() = history.size | |
| 143 | + | |
| 144 | + // ---- input ---- | |
| 145 | + | |
| 146 | + fun feed(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size) { | |
| 147 | + decoder.decode(bytes, offset, length) { process(it) } | |
| 148 | + } | |
| 149 | + | |
| 150 | + fun feed(text: String) = feed(text.toByteArray()) | |
| 151 | + | |
| 152 | + private fun process(c: Int) { | |
| 153 | + // C0 controls act in the middle of most sequences, as in a VT. | |
| 154 | + if (c < 0x20 && state != State.Osc && state != State.Ignore && state != State.IgnoreEscape) { | |
| 155 | + when (c) { | |
| 156 | + 0x1b -> { | |
| 157 | + state = State.Escape | |
| 158 | + intermediate = 0 | |
| 159 | + } | |
| 160 | + 0x18, 0x1a -> state = State.Ground | |
| 161 | + else -> control(c) | |
| 162 | + } | |
| 163 | + return | |
| 164 | + } | |
| 165 | + when (state) { | |
| 166 | + State.Ground -> print(c) | |
| 167 | + State.Escape -> escape(c) | |
| 168 | + State.EscapeIntermediate -> escapeIntermediate(c) | |
| 169 | + State.Charset -> { | |
| 170 | + val graphics = c == '0'.code | |
| 171 | + if (charsetTarget == 0) g0Graphics = graphics else if (charsetTarget == 1) g1Graphics = graphics | |
| 172 | + state = State.Ground | |
| 173 | + } | |
| 174 | + State.Csi -> csiChar(c) | |
| 175 | + State.Osc -> oscChar(c) | |
| 176 | + State.Ignore -> if (c == 0x1b) state = State.IgnoreEscape else if (c == 0x07) state = State.Ground | |
| 177 | + State.IgnoreEscape -> state = if (c == '\\'.code) State.Ground else State.Ignore | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + private fun control(c: Int) { | |
| 182 | + when (c) { | |
| 183 | + 0x07 -> {} | |
| 184 | + 0x08 -> { | |
| 185 | + pendingWrap = false | |
| 186 | + if (cursorColumn > 0) cursorColumn-- | |
| 187 | + } | |
| 188 | + 0x09 -> tab(1) | |
| 189 | + 0x0a, 0x0b, 0x0c -> { | |
| 190 | + lineFeed() | |
| 191 | + if (newLineMode) carriageReturn() | |
| 192 | + } | |
| 193 | + 0x0d -> carriageReturn() | |
| 194 | + 0x0e -> shiftedOut = true | |
| 195 | + 0x0f -> shiftedOut = false | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + private fun escape(c: Int) { | |
| 200 | + state = State.Ground | |
| 201 | + when (c) { | |
| 202 | + '['.code -> startCsi() | |
| 203 | + ']'.code -> { | |
| 204 | + osc.setLength(0) | |
| 205 | + oscEscape = false | |
| 206 | + state = State.Osc | |
| 207 | + } | |
| 208 | + 'P'.code, 'X'.code, '^'.code, '_'.code -> state = State.Ignore | |
| 209 | + '('.code, ')'.code, '*'.code, '+'.code -> { | |
| 210 | + charsetTarget = c - '('.code | |
| 211 | + state = State.Charset | |
| 212 | + } | |
| 213 | + '7'.code -> saveCursor() | |
| 214 | + '8'.code -> restoreCursor() | |
| 215 | + 'D'.code -> lineFeed() | |
| 216 | + 'E'.code -> { | |
| 217 | + lineFeed() | |
| 218 | + carriageReturn() | |
| 219 | + } | |
| 220 | + 'M'.code -> reverseIndex() | |
| 221 | + 'H'.code -> if (cursorColumn < columns) tabStops[cursorColumn] = true | |
| 222 | + 'c'.code -> reset() | |
| 223 | + in 0x20..0x2f -> { | |
| 224 | + intermediate = c | |
| 225 | + state = State.EscapeIntermediate | |
| 226 | + } | |
| 227 | + // `=`, `>` (keypad modes), `\` (a lone string terminator) and the rest: nothing to do. | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + private fun escapeIntermediate(c: Int) { | |
| 232 | + if (c in 0x20..0x2f) return | |
| 233 | + state = State.Ground | |
| 234 | + if (intermediate == '#'.code && c == '8'.code) { | |
| 235 | + // DECALN: the screen full of Es. | |
| 236 | + for (line in screen) { | |
| 237 | + line.clear(0, columns, TermColor.DEFAULT) | |
| 238 | + for (i in 0 until columns) line.chars[i] = 'E'.code | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + // ---- printing ---- | |
| 244 | + | |
| 245 | + private fun print(c0: Int) { | |
| 246 | + var c = c0 | |
| 247 | + val graphics = if (shiftedOut) g1Graphics else g0Graphics | |
| 248 | + if (graphics && c in 0x5f..0x7e) c = DEC_GRAPHICS[c - 0x5f].code | |
| 249 | + val width = charWidth(c) | |
| 250 | + if (width == 0) return | |
| 251 | + if (pendingWrap) { | |
| 252 | + if (autoWrap) { | |
| 253 | + screen[cursorRow].wrapped = true | |
| 254 | + lineFeed() | |
| 255 | + cursorColumn = 0 | |
| 256 | + } | |
| 257 | + pendingWrap = false | |
| 258 | + } | |
| 259 | + if (width == 2 && cursorColumn == columns - 1) { | |
| 260 | + if (columns < 2) return | |
| 261 | + if (!autoWrap) return | |
| 262 | + // It doesn't fit at the end of the line; it goes on the next. | |
| 263 | + val line = screen[cursorRow] | |
| 264 | + clearWide(line, cursorColumn) | |
| 265 | + line.clear(cursorColumn, columns, bg) | |
| 266 | + line.wrapped = true | |
| 267 | + lineFeed() | |
| 268 | + cursorColumn = 0 | |
| 269 | + } | |
| 270 | + val line = screen[cursorRow] | |
| 271 | + if (insertMode) insertBlanks(line, cursorColumn, width) | |
| 272 | + clearWide(line, cursorColumn) | |
| 273 | + if (width == 2) clearWide(line, cursorColumn + 1) | |
| 274 | + line.chars[cursorColumn] = c | |
| 275 | + line.fg[cursorColumn] = fg | |
| 276 | + line.bg[cursorColumn] = bg | |
| 277 | + line.attrs[cursorColumn] = attrs or if (width == 2) Attr.WIDE else 0 | |
| 278 | + if (width == 2) { | |
| 279 | + line.chars[cursorColumn + 1] = 0 | |
| 280 | + line.fg[cursorColumn + 1] = fg | |
| 281 | + line.bg[cursorColumn + 1] = bg | |
| 282 | + line.attrs[cursorColumn + 1] = attrs or Attr.SPACER | |
| 283 | + } | |
| 284 | + lastPrinted = c | |
| 285 | + if (cursorColumn + width >= columns) { | |
| 286 | + cursorColumn = columns - 1 | |
| 287 | + pendingWrap = true | |
| 288 | + } else { | |
| 289 | + cursorColumn += width | |
| 290 | + } | |
| 291 | + } | |
| 292 | + | |
| 293 | + /** Overwriting either half of a wide character blanks the other half. */ | |
| 294 | + private fun clearWide(line: Line, column: Int) { | |
| 295 | + if (column !in 0 until line.columns) return | |
| 296 | + val a = line.attrs[column] | |
| 297 | + if (a and Attr.SPACER != 0 && column > 0) { | |
| 298 | + line.chars[column - 1] = 0 | |
| 299 | + line.attrs[column - 1] = line.attrs[column - 1] and Attr.WIDE.inv() | |
| 300 | + } | |
| 301 | + if (a and Attr.WIDE != 0 && column + 1 < line.columns) { | |
| 302 | + line.chars[column + 1] = 0 | |
| 303 | + line.attrs[column + 1] = line.attrs[column + 1] and Attr.SPACER.inv() | |
| 304 | + } | |
| 305 | + line.attrs[column] = a and (Attr.WIDE or Attr.SPACER).inv() | |
| 306 | + } | |
| 307 | + | |
| 308 | + private fun carriageReturn() { | |
| 309 | + cursorColumn = 0 | |
| 310 | + pendingWrap = false | |
| 311 | + } | |
| 312 | + | |
| 313 | + private fun lineFeed() { | |
| 314 | + pendingWrap = false | |
| 315 | + if (cursorRow == scrollBottom) { | |
| 316 | + scrollUp(1) | |
| 317 | + } else if (cursorRow < rows - 1) { | |
| 318 | + cursorRow++ | |
| 319 | + } | |
| 320 | + } | |
| 321 | + | |
| 322 | + private fun reverseIndex() { | |
| 323 | + pendingWrap = false | |
| 324 | + if (cursorRow == scrollTop) scrollDown(1) else if (cursorRow > 0) cursorRow-- | |
| 325 | + } | |
| 326 | + | |
| 327 | + private fun tab(count: Int) { | |
| 328 | + pendingWrap = false | |
| 329 | + repeat(count) { | |
| 330 | + var column = cursorColumn + 1 | |
| 331 | + while (column < columns - 1 && !tabStops[column]) column++ | |
| 332 | + cursorColumn = column.coerceAtMost(columns - 1) | |
| 333 | + } | |
| 334 | + } | |
| 335 | + | |
| 336 | + private fun backTab(count: Int) { | |
| 337 | + pendingWrap = false | |
| 338 | + repeat(count) { | |
| 339 | + var column = cursorColumn - 1 | |
| 340 | + while (column > 0 && !tabStops[column]) column-- | |
| 341 | + cursorColumn = column.coerceAtLeast(0) | |
| 342 | + } | |
| 343 | + } | |
| 344 | + | |
| 345 | + private fun blankLine() = Line(columns).also { if (bg != TermColor.DEFAULT) it.clear(0, columns, bg) } | |
| 346 | + | |
| 347 | + /** Scrolls the region up: its top lines go, into the scrollback when it's the main screen's top. */ | |
| 348 | + private fun scrollUp(count: Int, top: Int = scrollTop, bottom: Int = scrollBottom) { | |
| 349 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 350 | + repeat(n) { | |
| 351 | + val gone = screen.removeAt(top) | |
| 352 | + if (top == 0 && !altScreen) { | |
| 353 | + history.addLast(gone) | |
| 354 | + if (history.size > scrollbackLimit) history.removeFirst() else if (displayOffset > 0) displayOffset++ | |
| 355 | + displayOffset = displayOffset.coerceAtMost(history.size) | |
| 356 | + } | |
| 357 | + screen.add(bottom, blankLine()) | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + private fun scrollDown(count: Int, top: Int = scrollTop, bottom: Int = scrollBottom) { | |
| 362 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 363 | + repeat(n) { | |
| 364 | + screen.removeAt(bottom) | |
| 365 | + screen.add(top, blankLine()) | |
| 366 | + } | |
| 367 | + } | |
| 368 | + | |
| 369 | + private fun insertBlanks(line: Line, at: Int, count: Int) { | |
| 370 | + val n = count.coerceAtMost(columns - at) | |
| 371 | + clearWide(line, at) | |
| 372 | + for (i in columns - 1 downTo at + n) line.copyCell(i - n, i) | |
| 373 | + line.clear(at, at + n, bg) | |
| 374 | + if (line.attrs[columns - 1] and Attr.WIDE != 0) line.clear(columns - 1, columns, bg) | |
| 375 | + } | |
| 376 | + | |
| 377 | + // ---- CSI ---- | |
| 378 | + | |
| 379 | + private fun startCsi() { | |
| 380 | + state = State.Csi | |
| 381 | + paramCount = 0 | |
| 382 | + paramStarted = false | |
| 383 | + privateMarker = 0 | |
| 384 | + intermediate = 0 | |
| 385 | + params.fill(-1) | |
| 386 | + colon.fill(false) | |
| 387 | + } | |
| 388 | + | |
| 389 | + private fun csiChar(c: Int) { | |
| 390 | + when (c) { | |
| 391 | + in '0'.code..'9'.code -> { | |
| 392 | + if (paramCount == 0) paramCount = 1 | |
| 393 | + val ix = paramCount - 1 | |
| 394 | + if (ix < MAX_PARAMS) { | |
| 395 | + val current = params[ix].coerceAtLeast(0) | |
| 396 | + params[ix] = (current * 10 + (c - '0'.code)).coerceAtMost(65535) | |
| 397 | + } | |
| 398 | + paramStarted = true | |
| 399 | + } | |
| 400 | + ';'.code, ':'.code -> { | |
| 401 | + if (paramCount == 0) paramCount = 1 | |
| 402 | + if (paramCount < MAX_PARAMS) { | |
| 403 | + colon[paramCount] = c == ':'.code | |
| 404 | + paramCount++ | |
| 405 | + } | |
| 406 | + } | |
| 407 | + '?'.code, '>'.code, '='.code, '<'.code -> if (paramCount == 0) privateMarker = c | |
| 408 | + in 0x20..0x2f -> intermediate = c | |
| 409 | + in 0x40..0x7e -> { | |
| 410 | + state = State.Ground | |
| 411 | + csi(c) | |
| 412 | + } | |
| 413 | + else -> state = State.Ground | |
| 414 | + } | |
| 415 | + } | |
| 416 | + | |
| 417 | + private fun param(ix: Int, default: Int): Int { | |
| 418 | + if (ix >= paramCount) return default | |
| 419 | + val value = params[ix] | |
| 420 | + return if (value <= 0) default else value | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** A parameter where 0 is meaningful. */ | |
| 424 | + private fun raw(ix: Int, default: Int): Int = if (ix >= paramCount || params[ix] < 0) default else params[ix] | |
| 425 | + | |
| 426 | + private fun csi(final: Int) { | |
| 427 | + if (privateMarker == '?'.code) { | |
| 428 | + when (final) { | |
| 429 | + 'h'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) privateMode(raw(i, 0), true) | |
| 430 | + 'l'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) privateMode(raw(i, 0), false) | |
| 431 | + 'n'.code -> if (raw(0, 0) == 6) reportCursor(true) | |
| 432 | + } | |
| 433 | + return | |
| 434 | + } | |
| 435 | + if (privateMarker == '>'.code) { | |
| 436 | + if (final == 'c'.code) reply("\u001b[>0;100;0c") | |
| 437 | + return | |
| 438 | + } | |
| 439 | + if (privateMarker != 0) return | |
| 440 | + if (intermediate == ' '.code) { | |
| 441 | + if (final == 'q'.code) { | |
| 442 | + cursorShape = when (raw(0, 0)) { | |
| 443 | + 3, 4 -> CursorShape.Underline | |
| 444 | + 5, 6 -> CursorShape.Beam | |
| 445 | + else -> CursorShape.Block | |
| 446 | + } | |
| 447 | + } | |
| 448 | + return | |
| 449 | + } | |
| 450 | + if (intermediate == '!'.code) { | |
| 451 | + if (final == 'p'.code) softReset() | |
| 452 | + return | |
| 453 | + } | |
| 454 | + if (intermediate != 0) return | |
| 455 | + when (final) { | |
| 456 | + '@'.code -> { | |
| 457 | + pendingWrap = false | |
| 458 | + insertBlanks(screen[cursorRow], cursorColumn, param(0, 1)) | |
| 459 | + } | |
| 460 | + 'A'.code -> moveTo(cursorRow - param(0, 1), cursorColumn, clampToRegion = true) | |
| 461 | + 'B'.code, 'e'.code -> moveTo(cursorRow + param(0, 1), cursorColumn, clampToRegion = true) | |
| 462 | + 'C'.code, 'a'.code -> moveTo(cursorRow, cursorColumn + param(0, 1)) | |
| 463 | + 'D'.code -> moveTo(cursorRow, cursorColumn - param(0, 1)) | |
| 464 | + 'E'.code -> moveTo(cursorRow + param(0, 1), 0, clampToRegion = true) | |
| 465 | + 'F'.code -> moveTo(cursorRow - param(0, 1), 0, clampToRegion = true) | |
| 466 | + 'G'.code, '`'.code -> moveTo(cursorRow, param(0, 1) - 1) | |
| 467 | + 'H'.code, 'f'.code -> { | |
| 468 | + val row = param(0, 1) - 1 | |
| 469 | + val column = param(1, 1) - 1 | |
| 470 | + if (originMode) moveTo(row + scrollTop, column, clampToRegion = true) else moveTo(row, column) | |
| 471 | + } | |
| 472 | + 'd'.code -> { | |
| 473 | + val row = param(0, 1) - 1 | |
| 474 | + if (originMode) moveTo(row + scrollTop, cursorColumn, clampToRegion = true) else moveTo(row, cursorColumn) | |
| 475 | + } | |
| 476 | + 'I'.code -> tab(param(0, 1)) | |
| 477 | + 'Z'.code -> backTab(param(0, 1)) | |
| 478 | + 'J'.code -> eraseDisplay(raw(0, 0)) | |
| 479 | + 'K'.code -> eraseLine(raw(0, 0)) | |
| 480 | + 'L'.code -> if (cursorRow in scrollTop..scrollBottom) { | |
| 481 | + scrollDown(param(0, 1), cursorRow, scrollBottom) | |
| 482 | + carriageReturn() | |
| 483 | + } | |
| 484 | + 'M'.code -> if (cursorRow in scrollTop..scrollBottom) { | |
| 485 | + scrollUpInPlace(param(0, 1), cursorRow, scrollBottom) | |
| 486 | + carriageReturn() | |
| 487 | + } | |
| 488 | + 'P'.code -> deleteChars(param(0, 1)) | |
| 489 | + 'X'.code -> { | |
| 490 | + pendingWrap = false | |
| 491 | + val line = screen[cursorRow] | |
| 492 | + val end = (cursorColumn + param(0, 1)).coerceAtMost(columns) | |
| 493 | + clearWide(line, cursorColumn) | |
| 494 | + if (end < columns) clearWide(line, end - 1) | |
| 495 | + line.clear(cursorColumn, end, bg) | |
| 496 | + } | |
| 497 | + 'S'.code -> scrollUp(param(0, 1)) | |
| 498 | + 'T'.code -> scrollDown(param(0, 1)) | |
| 499 | + 'b'.code -> repeat(param(0, 1).coerceAtMost(65535)) { print(lastPrinted) } | |
| 500 | + 'c'.code -> if (raw(0, 0) == 0) reply("\u001b[?62;22c") | |
| 501 | + 'g'.code -> when (raw(0, 0)) { | |
| 502 | + 0 -> if (cursorColumn < columns) tabStops[cursorColumn] = false | |
| 503 | + 3 -> tabStops.fill(false) | |
| 504 | + } | |
| 505 | + 'h'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) ansiMode(raw(i, 0), true) | |
| 506 | + 'l'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) ansiMode(raw(i, 0), false) | |
| 507 | + 'm'.code -> sgr() | |
| 508 | + 'n'.code -> when (raw(0, 0)) { | |
| 509 | + 5 -> reply("\u001b[0n") | |
| 510 | + 6 -> reportCursor(false) | |
| 511 | + } | |
| 512 | + 'r'.code -> { | |
| 513 | + val top = param(0, 1) - 1 | |
| 514 | + val bottom = param(1, rows) - 1 | |
| 515 | + if (top < bottom && bottom < rows) { | |
| 516 | + scrollTop = top | |
| 517 | + scrollBottom = bottom | |
| 518 | + } else if (paramCount == 0) { | |
| 519 | + scrollTop = 0 | |
| 520 | + scrollBottom = rows - 1 | |
| 521 | + } | |
| 522 | + moveTo(if (originMode) scrollTop else 0, 0) | |
| 523 | + } | |
| 524 | + 's'.code -> saveCursor() | |
| 525 | + 'u'.code -> restoreCursor() | |
| 526 | + 't'.code -> if (raw(0, 0) == 18) reply("\u001b[8;$rows;${columns}t") | |
| 527 | + } | |
| 528 | + } | |
| 529 | + | |
| 530 | + /** DL: lines at the cursor go; blanks come in at the region's bottom. Never into the scrollback. */ | |
| 531 | + private fun scrollUpInPlace(count: Int, top: Int, bottom: Int) { | |
| 532 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 533 | + repeat(n) { | |
| 534 | + screen.removeAt(top) | |
| 535 | + screen.add(bottom, blankLine()) | |
| 536 | + } | |
| 537 | + } | |
| 538 | + | |
| 539 | + private fun deleteChars(count: Int) { | |
| 540 | + pendingWrap = false | |
| 541 | + val line = screen[cursorRow] | |
| 542 | + val n = count.coerceAtMost(columns - cursorColumn) | |
| 543 | + clearWide(line, cursorColumn) | |
| 544 | + if (cursorColumn + n < columns) clearWide(line, cursorColumn + n) | |
| 545 | + for (i in cursorColumn until columns - n) line.copyCell(i + n, i) | |
| 546 | + line.clear(columns - n, columns, bg) | |
| 547 | + } | |
| 548 | + | |
| 549 | + private fun moveTo(row: Int, column: Int, clampToRegion: Boolean = false) { | |
| 550 | + pendingWrap = false | |
| 551 | + val (top, bottom) = if (clampToRegion && cursorRow in scrollTop..scrollBottom) scrollTop to scrollBottom else 0 to rows - 1 | |
| 552 | + cursorRow = row.coerceIn(top, bottom) | |
| 553 | + cursorColumn = column.coerceIn(0, columns - 1) | |
| 554 | + } | |
| 555 | + | |
| 556 | + private fun eraseDisplay(mode: Int) { | |
| 557 | + pendingWrap = false | |
| 558 | + when (mode) { | |
| 559 | + 0 -> { | |
| 560 | + val line = screen[cursorRow] | |
| 561 | + clearWide(line, cursorColumn) | |
| 562 | + line.clear(cursorColumn, columns, bg) | |
| 563 | + line.wrapped = false | |
| 564 | + for (row in cursorRow + 1 until rows) blank(row) | |
| 565 | + } | |
| 566 | + 1 -> { | |
| 567 | + for (row in 0 until cursorRow) blank(row) | |
| 568 | + val line = screen[cursorRow] | |
| 569 | + if (cursorColumn + 1 < columns) clearWide(line, cursorColumn + 1) | |
| 570 | + line.clear(0, cursorColumn + 1, bg) | |
| 571 | + } | |
| 572 | + 2 -> for (row in 0 until rows) blank(row) | |
| 573 | + 3 -> { | |
| 574 | + history.clear() | |
| 575 | + displayOffset = 0 | |
| 576 | + } | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + private fun blank(row: Int) { | |
| 581 | + screen[row].clear(0, columns, bg) | |
| 582 | + screen[row].wrapped = false | |
| 583 | + } | |
| 584 | + | |
| 585 | + private fun eraseLine(mode: Int) { | |
| 586 | + pendingWrap = false | |
| 587 | + val line = screen[cursorRow] | |
| 588 | + when (mode) { | |
| 589 | + 0 -> { | |
| 590 | + clearWide(line, cursorColumn) | |
| 591 | + line.clear(cursorColumn, columns, bg) | |
| 592 | + line.wrapped = false | |
| 593 | + } | |
| 594 | + 1 -> { | |
| 595 | + if (cursorColumn + 1 < columns) clearWide(line, cursorColumn + 1) | |
| 596 | + line.clear(0, cursorColumn + 1, bg) | |
| 597 | + } | |
| 598 | + 2 -> { | |
| 599 | + line.clear(0, columns, bg) | |
| 600 | + line.wrapped = false | |
| 601 | + } | |
| 602 | + } | |
| 603 | + } | |
| 604 | + | |
| 605 | + private fun sgr() { | |
| 606 | + if (paramCount == 0) { | |
| 607 | + fg = TermColor.DEFAULT | |
| 608 | + bg = TermColor.DEFAULT | |
| 609 | + attrs = 0 | |
| 610 | + return | |
| 611 | + } | |
| 612 | + var i = 0 | |
| 613 | + while (i < paramCount) { | |
| 614 | + val p = raw(i, 0) | |
| 615 | + when (p) { | |
| 616 | + 0 -> { | |
| 617 | + fg = TermColor.DEFAULT | |
| 618 | + bg = TermColor.DEFAULT | |
| 619 | + attrs = 0 | |
| 620 | + } | |
| 621 | + 1 -> attrs = attrs or Attr.BOLD | |
| 622 | + 2 -> attrs = attrs or Attr.DIM | |
| 623 | + 3 -> attrs = attrs or Attr.ITALIC | |
| 624 | + 4 -> attrs = if (i + 1 < paramCount && colon[i + 1]) { | |
| 625 | + // `4:0` turns underline off; `4:3` and the like are styles of it. | |
| 626 | + i++ | |
| 627 | + if (raw(i, 1) == 0) attrs and Attr.UNDERLINE.inv() else attrs or Attr.UNDERLINE | |
| 628 | + } else { | |
| 629 | + attrs or Attr.UNDERLINE | |
| 630 | + } | |
| 631 | + 7 -> attrs = attrs or Attr.INVERSE | |
| 632 | + 8 -> attrs = attrs or Attr.HIDDEN | |
| 633 | + 9 -> attrs = attrs or Attr.STRIKE | |
| 634 | + 21 -> attrs = attrs or Attr.UNDERLINE | |
| 635 | + 22 -> attrs = attrs and (Attr.BOLD or Attr.DIM).inv() | |
| 636 | + 23 -> attrs = attrs and Attr.ITALIC.inv() | |
| 637 | + 24 -> attrs = attrs and Attr.UNDERLINE.inv() | |
| 638 | + 27 -> attrs = attrs and Attr.INVERSE.inv() | |
| 639 | + 28 -> attrs = attrs and Attr.HIDDEN.inv() | |
| 640 | + 29 -> attrs = attrs and Attr.STRIKE.inv() | |
| 641 | + in 30..37 -> fg = TermColor.indexed(p - 30) | |
| 642 | + 39 -> fg = TermColor.DEFAULT | |
| 643 | + in 40..47 -> bg = TermColor.indexed(p - 40) | |
| 644 | + 49 -> bg = TermColor.DEFAULT | |
| 645 | + in 90..97 -> fg = TermColor.indexed(p - 90 + 8) | |
| 646 | + in 100..107 -> bg = TermColor.indexed(p - 100 + 8) | |
| 647 | + 38, 48, 58 -> { | |
| 648 | + val (color, used) = extendedColor(i) | |
| 649 | + if (color != null) { | |
| 650 | + if (p == 38) fg = color else if (p == 48) bg = color | |
| 651 | + } | |
| 652 | + i += used | |
| 653 | + } | |
| 654 | + } | |
| 655 | + i++ | |
| 656 | + } | |
| 657 | + } | |
| 658 | + | |
| 659 | + /** `38;5;n`, `38;2;r;g;b` and their colon forms; returns the color and how many parameters it took. */ | |
| 660 | + private fun extendedColor(at: Int): Pair<Int?, Int> { | |
| 661 | + val colonForm = at + 1 < paramCount && colon[at + 1] | |
| 662 | + if (colonForm) { | |
| 663 | + var end = at + 1 | |
| 664 | + while (end + 1 < paramCount && colon[end + 1]) end++ | |
| 665 | + val count = end - at | |
| 666 | + val kind = raw(at + 1, -1) | |
| 667 | + return when { | |
| 668 | + kind == 5 && count >= 2 -> TermColor.indexed(raw(at + 2, 0)) to count | |
| 669 | + kind == 2 && count >= 5 -> TermColor.rgb(raw(end - 2, 0), raw(end - 1, 0), raw(end, 0)) to count | |
| 670 | + kind == 2 && count == 4 -> TermColor.rgb(raw(at + 2, 0), raw(at + 3, 0), raw(at + 4, 0)) to count | |
| 671 | + else -> null to count | |
| 672 | + } | |
| 673 | + } | |
| 674 | + return when (raw(at + 1, -1)) { | |
| 675 | + 5 -> if (at + 2 < paramCount) TermColor.indexed(raw(at + 2, 0)) to 2 else null to 1 | |
| 676 | + 2 -> if (at + 4 < paramCount) TermColor.rgb(raw(at + 2, 0), raw(at + 3, 0), raw(at + 4, 0)) to 4 else null to (paramCount - at - 1) | |
| 677 | + else -> null to 0 | |
| 678 | + } | |
| 679 | + } | |
| 680 | + | |
| 681 | + private fun privateMode(mode: Int, on: Boolean) { | |
| 682 | + when (mode) { | |
| 683 | + 1 -> applicationCursor = on | |
| 684 | + 6 -> { | |
| 685 | + originMode = on | |
| 686 | + moveTo(if (on) scrollTop else 0, 0) | |
| 687 | + } | |
| 688 | + 7 -> autoWrap = on | |
| 689 | + 25 -> cursorVisible = on | |
| 690 | + 47, 1047 -> switchScreen(on, clear = mode == 1047 && !on) | |
| 691 | + 1048 -> if (on) saveCursor() else restoreCursor() | |
| 692 | + 1049 -> if (on) { | |
| 693 | + saveCursor() | |
| 694 | + switchScreen(true, clear = false) | |
| 695 | + for (row in 0 until rows) blank(row) | |
| 696 | + } else { | |
| 697 | + switchScreen(false, clear = false) | |
| 698 | + restoreCursor() | |
| 699 | + } | |
| 700 | + 9, 1000, 1002, 1003 -> mouseTracking = if (on) mode else 0 | |
| 701 | + 1006 -> sgrMouse = on | |
| 702 | + 1007 -> alternateScroll = on | |
| 703 | + 2004 -> bracketedPaste = on | |
| 704 | + } | |
| 705 | + } | |
| 706 | + | |
| 707 | + private fun ansiMode(mode: Int, on: Boolean) { | |
| 708 | + when (mode) { | |
| 709 | + 4 -> insertMode = on | |
| 710 | + 20 -> newLineMode = on | |
| 711 | + } | |
| 712 | + } | |
| 713 | + | |
| 714 | + private fun switchScreen(alt: Boolean, clear: Boolean) { | |
| 715 | + if (alt == altScreen) return | |
| 716 | + if (!alt && clear) for (row in 0 until rows) alternate[row].clear(0, columns, TermColor.DEFAULT) | |
| 717 | + altScreen = alt | |
| 718 | + screen = if (alt) alternate else main | |
| 719 | + if (alt) displayOffset = 0 | |
| 720 | + pendingWrap = false | |
| 721 | + } | |
| 722 | + | |
| 723 | + private fun saveCursor() { | |
| 724 | + val saved = Saved(cursorRow, cursorColumn, fg, bg, attrs, originMode, g0Graphics, g1Graphics, shiftedOut) | |
| 725 | + if (altScreen) savedAlternate = saved else savedMain = saved | |
| 726 | + } | |
| 727 | + | |
| 728 | + private fun restoreCursor() { | |
| 729 | + val saved = (if (altScreen) savedAlternate else savedMain) | |
| 730 | + if (saved == null) { | |
| 731 | + moveTo(0, 0) | |
| 732 | + return | |
| 733 | + } | |
| 734 | + cursorRow = saved.row.coerceIn(0, rows - 1) | |
| 735 | + cursorColumn = saved.column.coerceIn(0, columns - 1) | |
| 736 | + fg = saved.fg | |
| 737 | + bg = saved.bg | |
| 738 | + attrs = saved.attrs | |
| 739 | + originMode = saved.origin | |
| 740 | + g0Graphics = saved.g0 | |
| 741 | + g1Graphics = saved.g1 | |
| 742 | + shiftedOut = saved.shifted | |
| 743 | + pendingWrap = false | |
| 744 | + } | |
| 745 | + | |
| 746 | + private fun reportCursor(private: Boolean) { | |
| 747 | + val row = if (originMode) cursorRow - scrollTop else cursorRow | |
| 748 | + reply("\u001b[${if (private) "?" else ""}${row + 1};${cursorColumn + 1}R") | |
| 749 | + } | |
| 750 | + | |
| 751 | + private fun softReset() { | |
| 752 | + cursorVisible = true | |
| 753 | + originMode = false | |
| 754 | + autoWrap = true | |
| 755 | + insertMode = false | |
| 756 | + applicationCursor = false | |
| 757 | + scrollTop = 0 | |
| 758 | + scrollBottom = rows - 1 | |
| 759 | + fg = TermColor.DEFAULT | |
| 760 | + bg = TermColor.DEFAULT | |
| 761 | + attrs = 0 | |
| 762 | + g0Graphics = false | |
| 763 | + g1Graphics = false | |
| 764 | + shiftedOut = false | |
| 765 | + savedMain = null | |
| 766 | + savedAlternate = null | |
| 767 | + } | |
| 768 | + | |
| 769 | + /** RIS: as the terminal started, scrollback included. */ | |
| 770 | + fun reset() { | |
| 771 | + softReset() | |
| 772 | + switchScreen(false, clear = true) | |
| 773 | + for (row in 0 until rows) blank(row) | |
| 774 | + history.clear() | |
| 775 | + displayOffset = 0 | |
| 776 | + cursorRow = 0 | |
| 777 | + cursorColumn = 0 | |
| 778 | + pendingWrap = false | |
| 779 | + bracketedPaste = false | |
| 780 | + mouseTracking = 0 | |
| 781 | + sgrMouse = false | |
| 782 | + alternateScroll = true | |
| 783 | + newLineMode = false | |
| 784 | + cursorShape = CursorShape.Block | |
| 785 | + resetTabs() | |
| 786 | + } | |
| 787 | + | |
| 788 | + private fun resetTabs() { | |
| 789 | + tabStops = BooleanArray(columns) { it % 8 == 0 && it > 0 } | |
| 790 | + } | |
| 791 | + | |
| 792 | + private fun reply(text: String) = respond(text.toByteArray()) | |
| 793 | + | |
| 794 | + // ---- OSC ---- | |
| 795 | + | |
| 796 | + private fun oscChar(c: Int) { | |
| 797 | + when { | |
| 798 | + c == 0x07 -> endOsc("\u0007") | |
| 799 | + oscEscape -> { | |
| 800 | + oscEscape = false | |
| 801 | + if (c == '\\'.code) endOsc("\u001b\\") else { | |
| 802 | + // ESC starts something new: the OSC is over. | |
| 803 | + state = State.Escape | |
| 804 | + escape(c) | |
| 805 | + } | |
| 806 | + } | |
| 807 | + c == 0x1b -> oscEscape = true | |
| 808 | + osc.length < MAX_OSC -> osc.appendCodePoint(c) | |
| 809 | + } | |
| 810 | + } | |
| 811 | + | |
| 812 | + private fun endOsc(terminator: String) { | |
| 813 | + state = State.Ground | |
| 814 | + val text = osc.toString() | |
| 815 | + osc.setLength(0) | |
| 816 | + val parts = text.split(';') | |
| 817 | + // Titles (0, 1, 2) and the rest are ignored; color queries are answered. | |
| 818 | + when (parts.getOrNull(0)) { | |
| 819 | + "4" -> { | |
| 820 | + var i = 1 | |
| 821 | + while (i + 1 < parts.size) { | |
| 822 | + val index = parts[i].toIntOrNull() | |
| 823 | + if (index != null && parts[i + 1] == "?") answerColor("4;$index", index, terminator) | |
| 824 | + i += 2 | |
| 825 | + } | |
| 826 | + } | |
| 827 | + "10", "11", "12" -> if (parts.getOrNull(1) == "?") { | |
| 828 | + val index = when (parts[0]) { | |
| 829 | + "10" -> 256 | |
| 830 | + "11" -> 257 | |
| 831 | + else -> 258 | |
| 832 | + } | |
| 833 | + answerColor(parts[0], index, terminator) | |
| 834 | + } | |
| 835 | + } | |
| 836 | + } | |
| 837 | + | |
| 838 | + private fun answerColor(prefix: String, index: Int, terminator: String) { | |
| 839 | + val rgb = colorOf(index) ?: return | |
| 840 | + fun channel(shift: Int): String { | |
| 841 | + val value = (rgb shr shift) and 0xff | |
| 842 | + return "%02x%02x".format(value, value) | |
| 843 | + } | |
| 844 | + reply("\u001b]$prefix;rgb:${channel(16)}/${channel(8)}/${channel(0)}$terminator") | |
| 845 | + } | |
| 846 | + | |
| 847 | + // ---- size ---- | |
| 848 | + | |
| 849 | + /** | |
| 850 | + * Fits the screen to a new size. Lines that no longer fit above the | |
| 851 | + * cursor go into the scrollback, and come back when the screen grows. | |
| 852 | + */ | |
| 853 | + fun resize(newColumns: Int, newRows: Int) { | |
| 854 | + val columns = newColumns.coerceAtLeast(1) | |
| 855 | + val rows = newRows.coerceAtLeast(1) | |
| 856 | + if (columns == this.columns && rows == this.rows) return | |
| 857 | + if (columns != this.columns) { | |
| 858 | + for (line in main) line.resize(columns) | |
| 859 | + for (line in alternate) line.resize(columns) | |
| 860 | + // Scrollback lines keep their width until they come back on screen. | |
| 861 | + this.columns = columns | |
| 862 | + } | |
| 863 | + if (rows != this.rows) { | |
| 864 | + resizeRows(main, rows, !altScreen, withHistory = true) | |
| 865 | + resizeRows(alternate, rows, altScreen, withHistory = false) | |
| 866 | + // The cursor saved on the main screen moves with its lines. | |
| 867 | + this.rows = rows | |
| 868 | + } | |
| 869 | + scrollTop = 0 | |
| 870 | + scrollBottom = this.rows - 1 | |
| 871 | + resetTabs() | |
| 872 | + cursorRow = cursorRow.coerceIn(0, this.rows - 1) | |
| 873 | + cursorColumn = cursorColumn.coerceIn(0, this.columns - 1) | |
| 874 | + pendingWrap = false | |
| 875 | + displayOffset = displayOffset.coerceAtMost(history.size) | |
| 876 | + } | |
| 877 | + | |
| 878 | + private fun resizeRows(lines: ArrayList<Line>, rows: Int, active: Boolean, withHistory: Boolean) { | |
| 879 | + val old = lines.size | |
| 880 | + var shift = 0 | |
| 881 | + if (rows < old) { | |
| 882 | + var excess = old - rows | |
| 883 | + // Blank lines below the cursor go first. | |
| 884 | + val cursor = if (active) cursorRow else savedRow(withHistory) ?: (old - 1) | |
| 885 | + while (excess > 0 && lines.size - 1 > cursor && lines.last().isBlank()) { | |
| 886 | + lines.removeAt(lines.size - 1) | |
| 887 | + excess-- | |
| 888 | + } | |
| 889 | + while (excess > 0) { | |
| 890 | + val top = lines.removeAt(0) | |
| 891 | + if (withHistory) { | |
| 892 | + history.addLast(top) | |
| 893 | + if (history.size > scrollbackLimit) history.removeFirst() | |
| 894 | + } | |
| 895 | + shift-- | |
| 896 | + excess-- | |
| 897 | + } | |
| 898 | + } else if (rows > old) { | |
| 899 | + var missing = rows - old | |
| 900 | + while (missing > 0 && withHistory && history.isNotEmpty() && lines.size > 0) { | |
| 901 | + val line = history.removeLast() | |
| 902 | + line.resize(columns) | |
| 903 | + lines.add(0, line) | |
| 904 | + shift++ | |
| 905 | + missing-- | |
| 906 | + } | |
| 907 | + while (missing > 0) { | |
| 908 | + lines.add(Line(columns)) | |
| 909 | + missing-- | |
| 910 | + } | |
| 911 | + } | |
| 912 | + if (shift != 0) { | |
| 913 | + if (active) cursorRow = (cursorRow + shift).coerceIn(0, rows - 1) | |
| 914 | + adjustSaved(withHistory, shift, rows) | |
| 915 | + } | |
| 916 | + } | |
| 917 | + | |
| 918 | + private fun savedRow(main: Boolean): Int? = (if (main) savedMain else savedAlternate)?.row | |
| 919 | + | |
| 920 | + private fun adjustSaved(main: Boolean, shift: Int, rows: Int) { | |
| 921 | + val saved = (if (main) savedMain else savedAlternate) ?: return | |
| 922 | + val moved = Saved( | |
| 923 | + (saved.row + shift).coerceIn(0, rows - 1), saved.column, saved.fg, saved.bg, saved.attrs, | |
| 924 | + saved.origin, saved.g0, saved.g1, saved.shifted, | |
| 925 | + ) | |
| 926 | + if (main) savedMain = moved else savedAlternate = moved | |
| 927 | + } | |
| 928 | + | |
| 929 | + // ---- reading ---- | |
| 930 | + | |
| 931 | + /** The line shown at screen row [row], counting the scrollback the view is scrolled into. */ | |
| 932 | + fun visibleLine(row: Int): Line { | |
| 933 | + if (displayOffset == 0) return screen[row] | |
| 934 | + val index = history.size - displayOffset + row | |
| 935 | + return if (index < history.size) history[index] else screen[index - history.size] | |
| 936 | + } | |
| 937 | + | |
| 938 | + /** A line of the screen itself, ignoring the scrollback. */ | |
| 939 | + fun screenLine(row: Int): Line = screen[row] | |
| 940 | + | |
| 941 | + /** Scrolls the view [lines] up into the scrollback (negative: back down). */ | |
| 942 | + fun scrollDisplay(lines: Int) { | |
| 943 | + if (altScreen) return | |
| 944 | + displayOffset = (displayOffset + lines).coerceIn(0, history.size) | |
| 945 | + } | |
| 946 | + | |
| 947 | + fun scrollToBottom() { | |
| 948 | + displayOffset = 0 | |
| 949 | + } | |
| 950 | + | |
| 951 | + /** What the screen shows now, wrapped lines joined. */ | |
| 952 | + fun screenText(): String = joinLines((0 until rows).map { visibleLine(it) }) | |
| 953 | + | |
| 954 | + /** The scrollback and the screen. */ | |
| 955 | + fun allText(): String = joinLines(if (altScreen) screen.toList() else history.toList() + screen) | |
| 956 | + | |
| 957 | + private fun joinLines(lines: List<Line>): String { | |
| 958 | + val builder = StringBuilder() | |
| 959 | + for ((ix, line) in lines.withIndex()) { | |
| 960 | + builder.append(line.text(trim = !line.wrapped)) | |
| 961 | + if (!line.wrapped && ix < lines.size - 1) builder.append('\n') | |
| 962 | + } | |
| 963 | + return builder.toString().trimEnd('\n', ' ') | |
| 964 | + } | |
| 965 | + | |
| 966 | + companion object { | |
| 967 | + /** How many lines a terminal keeps above the screen, as the desktop's. */ | |
| 968 | + const val SCROLLBACK = 10_000 | |
| 969 | + private const val MAX_PARAMS = 32 | |
| 970 | + private const val MAX_OSC = 4096 | |
| 971 | + | |
| 972 | + /** DEC special graphics, for `0x5f..0x7e` once `ESC ( 0` picked it. */ | |
| 973 | + private const val DEC_GRAPHICS = " ◆▒␉␌␍␊°±␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·" | |
| 974 | + } | |
| 975 | + } | |
| @@ -0,0 +1,181 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** | |
| 4 | + * Decodes UTF-8 as it streams in: a character split between two chunks | |
| 5 | + * comes out whole, and a broken sequence comes out as U+FFFD. | |
| 6 | + */ | |
| 7 | + class Utf8Decoder { | |
| 8 | + private var codePoint = 0 | |
| 9 | + private var needed = 0 | |
| 10 | + private var minimum = 0 | |
| 11 | + | |
| 12 | + /** Feeds bytes; [emit] gets each code point they complete. */ | |
| 13 | + inline fun decode(bytes: ByteArray, offset: Int, length: Int, emit: (Int) -> Unit) { | |
| 14 | + for (i in offset until offset + length) { | |
| 15 | + val byte = bytes[i].toInt() and 0xff | |
| 16 | + var result = push(byte) | |
| 17 | + if (result == RETRY) { | |
| 18 | + emit(REPLACEMENT) | |
| 19 | + result = push(byte) | |
| 20 | + } | |
| 21 | + if (result >= 0) emit(result) else if (result == INVALID) emit(REPLACEMENT) | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** A code point, [MORE] while one is incomplete, [INVALID], or [RETRY] when this byte starts afresh. */ | |
| 26 | + fun push(byte: Int): Int { | |
| 27 | + if (needed == 0) { | |
| 28 | + return when { | |
| 29 | + byte < 0x80 -> byte | |
| 30 | + byte in 0xc2..0xdf -> start(byte and 0x1f, 1, 0x80) | |
| 31 | + byte in 0xe0..0xef -> start(byte and 0x0f, 2, 0x800) | |
| 32 | + byte in 0xf0..0xf4 -> start(byte and 0x07, 3, 0x10000) | |
| 33 | + else -> INVALID | |
| 34 | + } | |
| 35 | + } | |
| 36 | + if (byte and 0xc0 != 0x80) { | |
| 37 | + // The sequence broke off; this byte begins something new. | |
| 38 | + needed = 0 | |
| 39 | + return RETRY | |
| 40 | + } | |
| 41 | + codePoint = (codePoint shl 6) or (byte and 0x3f) | |
| 42 | + needed-- | |
| 43 | + if (needed > 0) return MORE | |
| 44 | + val done = codePoint | |
| 45 | + return if (done < minimum || done > 0x10ffff || done in 0xd800..0xdfff) INVALID else done | |
| 46 | + } | |
| 47 | + | |
| 48 | + private fun start(bits: Int, more: Int, min: Int): Int { | |
| 49 | + codePoint = bits | |
| 50 | + needed = more | |
| 51 | + minimum = min | |
| 52 | + return MORE | |
| 53 | + } | |
| 54 | + | |
| 55 | + companion object { | |
| 56 | + const val REPLACEMENT = 0xfffd | |
| 57 | + const val MORE = -1 | |
| 58 | + const val INVALID = -2 | |
| 59 | + const val RETRY = -3 | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * How many cells a character takes: 0 for combining marks and other | |
| 65 | + * zero-width characters, 2 for East Asian wide characters and emoji, 1 | |
| 66 | + * for the rest. A compact take on `wcwidth`. | |
| 67 | + */ | |
| 68 | + fun charWidth(c: Int): Int { | |
| 69 | + if (c < 0x300) return 1 | |
| 70 | + when (Character.getType(c).toByte()) { | |
| 71 | + Character.NON_SPACING_MARK, Character.ENCLOSING_MARK, Character.FORMAT -> return 0 | |
| 72 | + } | |
| 73 | + if (c == 0x200b || c in 0xfe00..0xfe0f || c in 0xe0100..0xe01ef) return 0 | |
| 74 | + return if (isWide(c)) 2 else 1 | |
| 75 | + } | |
| 76 | + | |
| 77 | + private val WIDE_RANGES = intArrayOf( | |
| 78 | + 0x1100, 0x115f, | |
| 79 | + 0x231a, 0x231b, | |
| 80 | + 0x2329, 0x232a, | |
| 81 | + 0x23e9, 0x23ec, | |
| 82 | + 0x23f0, 0x23f0, | |
| 83 | + 0x23f3, 0x23f3, | |
| 84 | + 0x25fd, 0x25fe, | |
| 85 | + 0x2614, 0x2615, | |
| 86 | + 0x2648, 0x2653, | |
| 87 | + 0x267f, 0x267f, | |
| 88 | + 0x2693, 0x2693, | |
| 89 | + 0x26a1, 0x26a1, | |
| 90 | + 0x26aa, 0x26ab, | |
| 91 | + 0x26bd, 0x26be, | |
| 92 | + 0x26c4, 0x26c5, | |
| 93 | + 0x26ce, 0x26ce, | |
| 94 | + 0x26d4, 0x26d4, | |
| 95 | + 0x26ea, 0x26ea, | |
| 96 | + 0x26f2, 0x26f3, | |
| 97 | + 0x26f5, 0x26f5, | |
| 98 | + 0x26fa, 0x26fa, | |
| 99 | + 0x26fd, 0x26fd, | |
| 100 | + 0x2705, 0x2705, | |
| 101 | + 0x270a, 0x270b, | |
| 102 | + 0x2728, 0x2728, | |
| 103 | + 0x274c, 0x274c, | |
| 104 | + 0x274e, 0x274e, | |
| 105 | + 0x2753, 0x2755, | |
| 106 | + 0x2757, 0x2757, | |
| 107 | + 0x2795, 0x2797, | |
| 108 | + 0x27b0, 0x27b0, | |
| 109 | + 0x27bf, 0x27bf, | |
| 110 | + 0x2b1b, 0x2b1c, | |
| 111 | + 0x2b50, 0x2b50, | |
| 112 | + 0x2b55, 0x2b55, | |
| 113 | + 0x2e80, 0x303e, | |
| 114 | + 0x3041, 0x33ff, | |
| 115 | + 0x3400, 0x4dbf, | |
| 116 | + 0x4e00, 0x9fff, | |
| 117 | + 0xa000, 0xa4cf, | |
| 118 | + 0xa960, 0xa97f, | |
| 119 | + 0xac00, 0xd7a3, | |
| 120 | + 0xf900, 0xfaff, | |
| 121 | + 0xfe10, 0xfe19, | |
| 122 | + 0xfe30, 0xfe6f, | |
| 123 | + 0xff00, 0xff60, | |
| 124 | + 0xffe0, 0xffe6, | |
| 125 | + 0x16fe0, 0x18cff, | |
| 126 | + 0x1b000, 0x1b2ff, | |
| 127 | + 0x1f004, 0x1f004, | |
| 128 | + 0x1f0cf, 0x1f0cf, | |
| 129 | + 0x1f18e, 0x1f18e, | |
| 130 | + 0x1f191, 0x1f19a, | |
| 131 | + 0x1f200, 0x1f251, | |
| 132 | + 0x1f300, 0x1f320, | |
| 133 | + 0x1f32d, 0x1f335, | |
| 134 | + 0x1f337, 0x1f37c, | |
| 135 | + 0x1f37e, 0x1f393, | |
| 136 | + 0x1f3a0, 0x1f3ca, | |
| 137 | + 0x1f3cf, 0x1f3d3, | |
| 138 | + 0x1f3e0, 0x1f3f0, | |
| 139 | + 0x1f3f4, 0x1f3f4, | |
| 140 | + 0x1f3f8, 0x1f43e, | |
| 141 | + 0x1f440, 0x1f440, | |
| 142 | + 0x1f442, 0x1f4fc, | |
| 143 | + 0x1f4ff, 0x1f53d, | |
| 144 | + 0x1f54b, 0x1f54e, | |
| 145 | + 0x1f550, 0x1f567, | |
| 146 | + 0x1f57a, 0x1f57a, | |
| 147 | + 0x1f595, 0x1f596, | |
| 148 | + 0x1f5a4, 0x1f5a4, | |
| 149 | + 0x1f5fb, 0x1f64f, | |
| 150 | + 0x1f680, 0x1f6c5, | |
| 151 | + 0x1f6cc, 0x1f6cc, | |
| 152 | + 0x1f6d0, 0x1f6d2, | |
| 153 | + 0x1f6d5, 0x1f6d7, | |
| 154 | + 0x1f6eb, 0x1f6ec, | |
| 155 | + 0x1f6f4, 0x1f6fc, | |
| 156 | + 0x1f7e0, 0x1f7eb, | |
| 157 | + 0x1f90c, 0x1f93a, | |
| 158 | + 0x1f93c, 0x1f945, | |
| 159 | + 0x1f947, 0x1f9ff, | |
| 160 | + 0x1fa70, 0x1faff, | |
| 161 | + 0x20000, 0x2fffd, | |
| 162 | + 0x30000, 0x3fffd, | |
| 163 | + ) | |
| 164 | + | |
| 165 | + private fun isWide(c: Int): Boolean { | |
| 166 | + if (c < WIDE_RANGES[0]) return false | |
| 167 | + // Binary search over the range pairs. | |
| 168 | + var low = 0 | |
| 169 | + var high = WIDE_RANGES.size / 2 - 1 | |
| 170 | + while (low <= high) { | |
| 171 | + val mid = (low + high) ushr 1 | |
| 172 | + val start = WIDE_RANGES[mid * 2] | |
| 173 | + val end = WIDE_RANGES[mid * 2 + 1] | |
| 174 | + when { | |
| 175 | + c < start -> high = mid - 1 | |
| 176 | + c > end -> low = mid + 1 | |
| 177 | + else -> return true | |
| 178 | + } | |
| 179 | + } | |
| 180 | + return false | |
| 181 | + } | |
| @@ -47,6 +47,7 @@ enum class Lucide(val res: Int) { | ||
| 47 | 47 | CircleDot(R.drawable.lucide_circle_dot), |
| 48 | 48 | CircleStop(R.drawable.lucide_circle_stop), |
| 49 | 49 | CircleX(R.drawable.lucide_circle_x), |
| 50 | + ClipboardPaste(R.drawable.lucide_clipboard_paste), | |
| 50 | 51 | Clock(R.drawable.lucide_clock), |
| 51 | 52 | Code(R.drawable.lucide_code), |
| 52 | 53 | Contrast(R.drawable.lucide_contrast), |
| @@ -1,10 +1,449 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.device |
| 2 | 2 | |
| 3 | + import androidx.compose.foundation.background | |
| 4 | + import androidx.compose.foundation.clickable | |
| 5 | + import androidx.compose.foundation.gestures.detectTapGestures | |
| 6 | + import androidx.compose.foundation.horizontalScroll | |
| 7 | + import androidx.compose.foundation.layout.Arrangement | |
| 8 | + import androidx.compose.foundation.layout.Box | |
| 9 | + import androidx.compose.foundation.layout.Column | |
| 10 | + import androidx.compose.foundation.layout.ColumnScope | |
| 11 | + import androidx.compose.foundation.layout.Row | |
| 12 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 13 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | + import androidx.compose.foundation.layout.height | |
| 15 | + import androidx.compose.foundation.layout.padding | |
| 16 | + import androidx.compose.foundation.layout.size | |
| 17 | + import androidx.compose.foundation.layout.widthIn | |
| 18 | + import androidx.compose.foundation.rememberScrollState | |
| 19 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 20 | + import androidx.compose.foundation.verticalScroll | |
| 3 | 21 | import androidx.compose.runtime.Composable |
| 22 | + import androidx.compose.runtime.LaunchedEffect | |
| 23 | + import androidx.compose.runtime.getValue | |
| 24 | + import androidx.compose.runtime.mutableStateOf | |
| 25 | + import androidx.compose.runtime.remember | |
| 26 | + import androidx.compose.runtime.rememberUpdatedState | |
| 27 | + import androidx.compose.runtime.setValue | |
| 28 | + import androidx.compose.ui.Alignment | |
| 29 | + import androidx.compose.ui.Modifier | |
| 30 | + import androidx.compose.ui.draw.clip | |
| 31 | + import androidx.compose.ui.input.pointer.pointerInput | |
| 32 | + import androidx.compose.ui.text.TextStyle | |
| 33 | + import androidx.compose.ui.text.font.FontWeight | |
| 34 | + import androidx.compose.ui.text.style.TextAlign | |
| 35 | + import androidx.compose.ui.unit.dp | |
| 36 | + import androidx.compose.ui.unit.sp | |
| 4 | 37 | import cc.vectrum.code.data.AppState |
| 5 | 38 | import cc.vectrum.code.data.ThreadRef |
| 39 | + import cc.vectrum.code.theme.Cap | |
| 40 | + import cc.vectrum.code.theme.CapKind | |
| 41 | + import cc.vectrum.code.theme.CapSize | |
| 42 | + import cc.vectrum.code.theme.Led | |
| 43 | + import cc.vectrum.code.theme.Rule | |
| 44 | + import cc.vectrum.code.theme.StatusLed | |
| 45 | + import cc.vectrum.code.theme.Txt | |
| 46 | + import cc.vectrum.code.theme.metal | |
| 47 | + import cc.vectrum.code.theme.palette | |
| 48 | + import cc.vectrum.code.theme.raised | |
| 49 | + import cc.vectrum.code.theme.well | |
| 50 | + import cc.vectrum.code.ui.Lucide | |
| 51 | + import cc.vectrum.code.ui.LucideIcon | |
| 52 | + import cc.vectrum.code.ui.MenuRow | |
| 6 | 53 | import cc.vectrum.code.ui.Nav |
| 54 | + import cc.vectrum.code.ui.PopupMenu | |
| 55 | + import cc.vectrum.code.ui.PromptDialog | |
| 56 | + import kotlinx.coroutines.launch | |
| 7 | 57 | |
| 8 | - /** The host's simulators and emulators, next to a thread. Placeholder until it's built. */ | |
| 58 | + /** | |
| 59 | + * The host's simulators and emulators next to a thread, as the desktop's | |
| 60 | + * Device panel (src/ui/device/mod.rs): one tab per device. A tab without a | |
| 61 | + * device lists them to open or start; one with a device shows it live. Set | |
| 62 | + * up devices first, as the desktop asks. | |
| 63 | + */ | |
| 9 | 64 | @Composable |
| 10 | - fun DevicePanel(app: AppState, nav: Nav, ref: ThreadRef) {} | |
| 65 | + fun DevicePanel(app: AppState, nav: Nav, ref: ThreadRef) { | |
| 66 | + val host = app.host(ref.host) ?: return | |
| 67 | + val p = palette | |
| 68 | + val devices = remember(ref) { DeviceRegistry.of(host, ref) } | |
| 69 | + val calls = remember(host) { DeviceCalls(host) } | |
| 70 | + val heard = host.deviceState | |
| 71 | + val state = remember(heard, host.name) { DeviceHubState.read(heard, host.name) } | |
| 72 | + | |
| 73 | + // Follow the host's devices while the panel shows; a new connection follows them again. | |
| 74 | + LaunchedEffect(host, host.connected) { | |
| 75 | + if (host.connected) calls.watch() | |
| 76 | + } | |
| 77 | + // List them once set up; never install or start anything before that. | |
| 78 | + var listed by remember(ref) { mutableStateOf(false) } | |
| 79 | + fun refresh() { | |
| 80 | + if (state.needsSetup || !host.connected) return | |
| 81 | + listed = true | |
| 82 | + DeviceRegistry.scope.launch { calls.list() } | |
| 83 | + } | |
| 84 | + LaunchedEffect(host.connected, state.needsSetup, devices.active) { | |
| 85 | + if (!listed) refresh() | |
| 86 | + } | |
| 87 | + LaunchedEffect(state) { devices.sync(state) } | |
| 88 | + | |
| 89 | + // The setup dialog, offered once when the panel opens on a host that needs it. | |
| 90 | + var setup by remember { mutableStateOf(false) } | |
| 91 | + var offered by remember(ref) { mutableStateOf(false) } | |
| 92 | + LaunchedEffect(heard != null, state.needsSetup) { | |
| 93 | + if (!offered && heard != null && state.needsSetup) { | |
| 94 | + offered = true | |
| 95 | + setup = true | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + fun close() { | |
| 100 | + nav.surface = null | |
| 101 | + } | |
| 102 | + | |
| 103 | + fun removeTab(id: Long) { | |
| 104 | + if (devices.remove(id)) close() | |
| 105 | + listed = false | |
| 106 | + } | |
| 107 | + | |
| 108 | + fun select(device: DeviceSummary) { | |
| 109 | + if (devices.pending != null) return | |
| 110 | + devices.error = null | |
| 111 | + devices.pending = device | |
| 112 | + DeviceRegistry.scope.launch { | |
| 113 | + calls.open(ref.thread, device.id, boot = true) | |
| 114 | + .onSuccess { session -> | |
| 115 | + val now = DeviceHubState.read(host.deviceState, host.name) | |
| 116 | + devices.show(now.device(session.hostId, session.deviceId) ?: device) | |
| 117 | + } | |
| 118 | + .onFailure { devices.error = it.message } | |
| 119 | + devices.pending = null | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + fun powerOff(tab: DeviceTab) { | |
| 124 | + val device = tab.device ?: return | |
| 125 | + devices.error = null | |
| 126 | + DeviceRegistry.scope.launch { | |
| 127 | + calls.close(ref.thread, device.id, shutdown = true) | |
| 128 | + .onSuccess { if (devices.remove(tab.id)) close() } | |
| 129 | + .onFailure { devices.error = it.message } | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + Column(Modifier.fillMaxSize().raised(p, RoundedCornerShape(18.dp)).clip(RoundedCornerShape(18.dp))) { | |
| 134 | + DeviceTabs( | |
| 135 | + devices, | |
| 136 | + onSelect = { | |
| 137 | + devices.active = it | |
| 138 | + listed = false | |
| 139 | + }, | |
| 140 | + onClose = ::removeTab, | |
| 141 | + onNew = { | |
| 142 | + devices.newTab() | |
| 143 | + listed = false | |
| 144 | + }, | |
| 145 | + onClosePanel = ::close, | |
| 146 | + ) | |
| 147 | + Notices(state, devices) | |
| 148 | + Box( | |
| 149 | + Modifier | |
| 150 | + .weight(1f) | |
| 151 | + .fillMaxWidth() | |
| 152 | + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) | |
| 153 | + .well(p, RoundedCornerShape(12.dp)) | |
| 154 | + .clip(RoundedCornerShape(12.dp)), | |
| 155 | + ) { | |
| 156 | + val tab = devices.activeTab | |
| 157 | + val device = tab?.device | |
| 158 | + val pending = devices.pending | |
| 159 | + when { | |
| 160 | + tab != null && device != null -> DeviceView( | |
| 161 | + host = host, | |
| 162 | + calls = calls, | |
| 163 | + state = state, | |
| 164 | + device = device, | |
| 165 | + onClose = { removeTab(tab.id) }, | |
| 166 | + onPowerOff = { powerOff(tab) }, | |
| 167 | + ) | |
| 168 | + state.needsSetup -> SetUpPrompt { setup = true } | |
| 169 | + pending != null -> LoadingView( | |
| 170 | + pending.name, | |
| 171 | + "${state.hostLabel(pending.hostId)} · ${pending.version}", | |
| 172 | + if (pending.booted) "Opening device…" else "Starting device…", | |
| 173 | + ) | |
| 174 | + state.busy -> LoadingView( | |
| 175 | + "Devices", | |
| 176 | + "", | |
| 177 | + if (state.hostStatus == "installing") state.hostDetail ?: "Installing device support…" else "Finding devices…", | |
| 178 | + ) | |
| 179 | + else -> DeviceList( | |
| 180 | + state, | |
| 181 | + onPick = ::select, | |
| 182 | + onRetry = { DeviceRegistry.scope.launch { calls.retry() } }, | |
| 183 | + onRefresh = { | |
| 184 | + listed = false | |
| 185 | + refresh() | |
| 186 | + }, | |
| 187 | + ) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + if (setup) { | |
| 193 | + DeviceSetup(host, calls, state, onDismiss = { done -> | |
| 194 | + setup = false | |
| 195 | + if (!done) close() | |
| 196 | + }) | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** The tab strip: a tab per device, a new tab, and closing the panel. */ | |
| 201 | + @Composable | |
| 202 | + private fun DeviceTabs( | |
| 203 | + devices: ThreadDevices, | |
| 204 | + onSelect: (Long) -> Unit, | |
| 205 | + onClose: (Long) -> Unit, | |
| 206 | + onNew: () -> Unit, | |
| 207 | + onClosePanel: () -> Unit, | |
| 208 | + ) { | |
| 209 | + var renaming by remember { mutableStateOf<DeviceTab?>(null) } | |
| 210 | + Row( | |
| 211 | + Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 8.dp), | |
| 212 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 213 | + verticalAlignment = Alignment.CenterVertically, | |
| 214 | + ) { | |
| 215 | + Row( | |
| 216 | + Modifier.weight(1f).horizontalScroll(rememberScrollState()), | |
| 217 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 218 | + verticalAlignment = Alignment.CenterVertically, | |
| 219 | + ) { | |
| 220 | + for (tab in devices.tabs) { | |
| 221 | + DeviceTabChip( | |
| 222 | + tab, | |
| 223 | + active = tab.id == devices.active, | |
| 224 | + onSelect = { onSelect(tab.id) }, | |
| 225 | + onClose = { onClose(tab.id) }, | |
| 226 | + onRename = { renaming = tab }, | |
| 227 | + ) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + Cap(icon = Lucide.Plus, kind = CapKind.Ghost, size = CapSize.Mini, onClick = onNew) | |
| 231 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini, onClick = onClosePanel) | |
| 232 | + } | |
| 233 | + renaming?.let { tab -> | |
| 234 | + PromptDialog( | |
| 235 | + title = "Rename tab", | |
| 236 | + initial = tab.title, | |
| 237 | + confirm = "Rename", | |
| 238 | + onDismiss = { renaming = null }, | |
| 239 | + onConfirm = { name -> tab.name = name.ifBlank { null } }, | |
| 240 | + ) | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + @Composable | |
| 245 | + private fun DeviceTabChip(tab: DeviceTab, active: Boolean, onSelect: () -> Unit, onClose: () -> Unit, onRename: () -> Unit) { | |
| 246 | + val p = palette | |
| 247 | + val shape = RoundedCornerShape(9.dp) | |
| 248 | + var menu by remember { mutableStateOf(false) } | |
| 249 | + val select by rememberUpdatedState(onSelect) | |
| 250 | + Box { | |
| 251 | + Row( | |
| 252 | + Modifier | |
| 253 | + .height(28.dp) | |
| 254 | + .widthIn(max = 180.dp) | |
| 255 | + .then(if (active) Modifier.metal(shape, p.cap.brush, p.btn) else Modifier) | |
| 256 | + .clip(shape) | |
| 257 | + .pointerInput(tab.id) { detectTapGestures(onTap = { select() }, onLongPress = { menu = true }) } | |
| 258 | + .padding(start = 10.dp, end = 4.dp), | |
| 259 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 260 | + verticalAlignment = Alignment.CenterVertically, | |
| 261 | + ) { | |
| 262 | + LucideIcon(Lucide.Smartphone, size = 13.dp, tint = p.ink3) | |
| 263 | + Txt(tab.title, Modifier.weight(1f, fill = false), color = if (active) p.ink else p.ink3, size = 12.5.sp, maxLines = 1) | |
| 264 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini, onClick = onClose) | |
| 265 | + } | |
| 266 | + PopupMenu(open = menu, onDismiss = { menu = false }, minWidth = 140.dp) { | |
| 267 | + MenuRow("Rename", icon = Lucide.Pencil) { | |
| 268 | + menu = false | |
| 269 | + onRename() | |
| 270 | + } | |
| 271 | + MenuRow("Close", icon = Lucide.X) { | |
| 272 | + menu = false | |
| 273 | + onClose() | |
| 274 | + } | |
| 275 | + } | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + /** Lines under the tabs: the host's diagnostics, devices starting, and the last error. */ | |
| 280 | + @Composable | |
| 281 | + private fun Notices(state: DeviceHubState, devices: ThreadDevices) { | |
| 282 | + val p = palette | |
| 283 | + val activeHasDevice = devices.activeTab?.device != null | |
| 284 | + val detail = state.hostDetail | |
| 285 | + if (state.ready && !activeHasDevice && detail != null) Notice(detail) | |
| 286 | + val booting = state.booting(devices.ref.thread) | |
| 287 | + if (booting.isNotEmpty()) Notice("Starting ${booting.joinToString(", ")}… This can take a minute.") | |
| 288 | + val error = devices.error | |
| 289 | + if (error != null) { | |
| 290 | + Row( | |
| 291 | + Modifier.fillMaxWidth().background(p.red.copy(alpha = 0.06f)).padding(start = 14.dp, end = 8.dp, top = 7.dp, bottom = 7.dp), | |
| 292 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 293 | + verticalAlignment = Alignment.Top, | |
| 294 | + ) { | |
| 295 | + Txt(error, Modifier.weight(1f), color = p.red, size = 11.5.sp, lineHeight = 16.sp) | |
| 296 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini) { devices.error = null } | |
| 297 | + } | |
| 298 | + Rule() | |
| 299 | + } | |
| 300 | + } | |
| 301 | + | |
| 302 | + @Composable | |
| 303 | + private fun Notice(text: String) { | |
| 304 | + Txt(text, Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 7.dp), color = palette.ink3, size = 11.5.sp, lineHeight = 16.sp) | |
| 305 | + Rule() | |
| 306 | + } | |
| 307 | + | |
| 308 | + /** What stands in for a device while it opens, starts or connects. */ | |
| 309 | + @Composable | |
| 310 | + fun LoadingView(name: String, description: String, message: String, error: Boolean = false, action: (@Composable () -> Unit)? = null) { | |
| 311 | + val p = palette | |
| 312 | + Column( | |
| 313 | + Modifier.fillMaxSize().padding(horizontal = 24.dp), | |
| 314 | + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), | |
| 315 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 316 | + ) { | |
| 317 | + LucideIcon(Lucide.Smartphone, size = 24.dp, tint = p.ink3) | |
| 318 | + Txt(name, size = 14.sp, weight = FontWeight.Medium) | |
| 319 | + if (description.isNotEmpty()) Txt(description, color = p.ink3, size = 12.sp) | |
| 320 | + Row(Modifier.widthIn(max = 360.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 321 | + if (!error) StatusLed(Led.Waiting, pulse = true) | |
| 322 | + Txt(message, color = if (error) p.red else p.ink2, size = 12.5.sp, style = TextStyle(textAlign = TextAlign.Center)) | |
| 323 | + } | |
| 324 | + action?.invoke() | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** Before setup: what it's for, and the way into it. */ | |
| 329 | + @Composable | |
| 330 | + private fun SetUpPrompt(onSetUp: () -> Unit) { | |
| 331 | + val p = palette | |
| 332 | + Column( | |
| 333 | + Modifier.fillMaxSize().padding(horizontal = 24.dp), | |
| 334 | + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), | |
| 335 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 336 | + ) { | |
| 337 | + LucideIcon(Lucide.Smartphone, size = 24.dp, tint = p.ink3) | |
| 338 | + Txt("Set up devices", size = 14.sp, weight = FontWeight.Medium) | |
| 339 | + Txt( | |
| 340 | + "Review what runs on this machine before using simulators and emulators.", | |
| 341 | + Modifier.widthIn(max = 320.dp), | |
| 342 | + color = p.ink3, | |
| 343 | + size = 12.5.sp, | |
| 344 | + lineHeight = 18.sp, | |
| 345 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 346 | + ) | |
| 347 | + Cap(label = "Set up devices", kind = CapKind.Primary, size = CapSize.Compact, onClick = onSetUp) | |
| 348 | + } | |
| 349 | + } | |
| 350 | + | |
| 351 | + /** The devices to open or start, grouped by platform, running ones first. */ | |
| 352 | + @Composable | |
| 353 | + private fun DeviceList(state: DeviceHubState, onPick: (DeviceSummary) -> Unit, onRetry: () -> Unit, onRefresh: () -> Unit) { | |
| 354 | + val p = palette | |
| 355 | + val groups = listOf("ios", "android").mapNotNull { platform -> | |
| 356 | + val list = state.devices.filter { it.platform == platform }.sortedWith(compareBy({ !it.booted }, { it.name })) | |
| 357 | + if (list.isEmpty()) null else platform to list | |
| 358 | + } | |
| 359 | + val empty = groups.isEmpty() | |
| 360 | + val androidMissing = state.ready && state.devices.none { it.platform == "android" } && | |
| 361 | + state.hosts.flatMap { it.platforms }.none { it.platform == "android" && !it.available } | |
| 362 | + Column( | |
| 363 | + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 28.dp), | |
| 364 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 365 | + verticalArrangement = if (empty) Arrangement.Center else Arrangement.Top, | |
| 366 | + ) { | |
| 367 | + Column( | |
| 368 | + Modifier.widthIn(max = 560.dp).fillMaxWidth(), | |
| 369 | + verticalArrangement = Arrangement.spacedBy(22.dp), | |
| 370 | + horizontalAlignment = if (empty) Alignment.CenterHorizontally else Alignment.Start, | |
| 371 | + ) { | |
| 372 | + if (empty) { | |
| 373 | + LucideIcon(Lucide.Smartphone, size = 22.dp, tint = p.ink3) | |
| 374 | + Txt( | |
| 375 | + if (state.failed) state.hostDetail ?: "The device hub failed to start." else "No simulators or emulators were found on this machine.", | |
| 376 | + Modifier.widthIn(max = 340.dp), | |
| 377 | + color = p.ink3, | |
| 378 | + size = 13.sp, | |
| 379 | + lineHeight = 19.sp, | |
| 380 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 381 | + ) | |
| 382 | + if (state.failed) Cap(label = "Retry", size = CapSize.Compact, onClick = onRetry) | |
| 383 | + } | |
| 384 | + if (state.ready) { | |
| 385 | + for ((platform, devices) in groups) { | |
| 386 | + DeviceGroup(platform) { | |
| 387 | + for (device in devices) DeviceRow(state, device) { onPick(device) } | |
| 388 | + } | |
| 389 | + } | |
| 390 | + } | |
| 391 | + if (androidMissing) { | |
| 392 | + Txt( | |
| 393 | + "No Android virtual devices found. Create one in Android Studio's Device Manager, then refresh.", | |
| 394 | + Modifier.widthIn(max = 340.dp), | |
| 395 | + color = p.ink3, | |
| 396 | + size = 11.5.sp, | |
| 397 | + lineHeight = 16.sp, | |
| 398 | + ) | |
| 399 | + } | |
| 400 | + Cap( | |
| 401 | + label = "Refresh devices", | |
| 402 | + kind = if (empty) CapKind.Standard else CapKind.Ghost, | |
| 403 | + size = CapSize.Compact, | |
| 404 | + onClick = onRefresh, | |
| 405 | + ) | |
| 406 | + } | |
| 407 | + } | |
| 408 | + } | |
| 409 | + | |
| 410 | + @Composable | |
| 411 | + private fun DeviceGroup(platform: String, rows: @Composable ColumnScope.() -> Unit) { | |
| 412 | + val p = palette | |
| 413 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 414 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 415 | + LucideIcon(Lucide.Smartphone, size = 14.dp, tint = p.ink3) | |
| 416 | + Txt(platformGroup(platform), color = p.ink3, size = 12.5.sp, weight = FontWeight.Medium) | |
| 417 | + } | |
| 418 | + Column(Modifier.fillMaxWidth().well(p, RoundedCornerShape(14.dp)).padding(4.dp), content = rows) | |
| 419 | + } | |
| 420 | + } | |
| 421 | + | |
| 422 | + @Composable | |
| 423 | + private fun DeviceRow(state: DeviceHubState, device: DeviceSummary, onClick: () -> Unit) { | |
| 424 | + val p = palette | |
| 425 | + val shape = RoundedCornerShape(10.dp) | |
| 426 | + Row( | |
| 427 | + Modifier | |
| 428 | + .fillMaxWidth() | |
| 429 | + .clip(shape) | |
| 430 | + .clickable(onClick = onClick) | |
| 431 | + .padding(horizontal = 10.dp, vertical = 8.dp), | |
| 432 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 433 | + verticalAlignment = Alignment.CenterVertically, | |
| 434 | + ) { | |
| 435 | + Box(Modifier.size(32.dp).well(p, RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { | |
| 436 | + LucideIcon(Lucide.Smartphone, size = 15.dp, tint = p.ink2) | |
| 437 | + } | |
| 438 | + Column(Modifier.weight(1f)) { | |
| 439 | + Txt(device.name, size = 13.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 440 | + Txt( | |
| 441 | + "${state.hostLabel(device.hostId)} · ${device.version} · ${if (device.booted) "Running" else "Stopped"}", | |
| 442 | + color = p.ink3, | |
| 443 | + size = 11.5.sp, | |
| 444 | + maxLines = 1, | |
| 445 | + ) | |
| 446 | + } | |
| 447 | + Txt(if (device.booted) "Open" else "Start", color = p.ink3, size = 11.5.sp) | |
| 448 | + } | |
| 449 | + } | |
| @@ -0,0 +1,549 @@ | ||
| 1 | + package cc.vectrum.code.ui.device | |
| 2 | + | |
| 3 | + import android.content.ContentValues | |
| 4 | + import android.content.Context | |
| 5 | + import android.content.Intent | |
| 6 | + import android.graphics.SurfaceTexture | |
| 7 | + import android.net.Uri | |
| 8 | + import android.provider.MediaStore | |
| 9 | + import android.view.Surface | |
| 10 | + import android.view.TextureView | |
| 11 | + import androidx.compose.foundation.Image | |
| 12 | + import androidx.compose.foundation.background | |
| 13 | + import androidx.compose.foundation.gestures.awaitEachGesture | |
| 14 | + import androidx.compose.foundation.gestures.awaitFirstDown | |
| 15 | + import androidx.compose.foundation.layout.Arrangement | |
| 16 | + import androidx.compose.foundation.layout.Box | |
| 17 | + import androidx.compose.foundation.layout.BoxWithConstraints | |
| 18 | + import androidx.compose.foundation.layout.Column | |
| 19 | + import androidx.compose.foundation.layout.ColumnScope | |
| 20 | + import androidx.compose.foundation.layout.Row | |
| 21 | + import androidx.compose.foundation.layout.Spacer | |
| 22 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 23 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 24 | + import androidx.compose.foundation.layout.height | |
| 25 | + import androidx.compose.foundation.layout.heightIn | |
| 26 | + import androidx.compose.foundation.layout.padding | |
| 27 | + import androidx.compose.foundation.layout.size | |
| 28 | + import androidx.compose.foundation.layout.width | |
| 29 | + import androidx.compose.foundation.rememberScrollState | |
| 30 | + import androidx.compose.foundation.verticalScroll | |
| 31 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 32 | + import androidx.compose.runtime.Composable | |
| 33 | + import androidx.compose.runtime.DisposableEffect | |
| 34 | + import androidx.compose.runtime.LaunchedEffect | |
| 35 | + import androidx.compose.runtime.getValue | |
| 36 | + import androidx.compose.runtime.mutableStateOf | |
| 37 | + import androidx.compose.runtime.remember | |
| 38 | + import androidx.compose.runtime.rememberCoroutineScope | |
| 39 | + import androidx.compose.runtime.setValue | |
| 40 | + import androidx.compose.ui.Alignment | |
| 41 | + import androidx.compose.ui.Modifier | |
| 42 | + import androidx.compose.ui.draw.alpha | |
| 43 | + import androidx.compose.ui.draw.clip | |
| 44 | + import androidx.compose.ui.graphics.Color | |
| 45 | + import androidx.compose.ui.input.pointer.pointerInput | |
| 46 | + import androidx.compose.ui.input.pointer.positionChange | |
| 47 | + import androidx.compose.ui.layout.ContentScale | |
| 48 | + import androidx.compose.ui.platform.LocalContext | |
| 49 | + import androidx.compose.ui.text.font.FontWeight | |
| 50 | + import androidx.compose.ui.text.input.TextFieldValue | |
| 51 | + import androidx.compose.ui.unit.dp | |
| 52 | + import androidx.compose.ui.unit.sp | |
| 53 | + import androidx.compose.ui.viewinterop.AndroidView | |
| 54 | + import cc.vectrum.code.data.HostState | |
| 55 | + import cc.vectrum.code.term.TermKey | |
| 56 | + import cc.vectrum.code.theme.Cap | |
| 57 | + import cc.vectrum.code.theme.CapKind | |
| 58 | + import cc.vectrum.code.theme.CapSize | |
| 59 | + import cc.vectrum.code.theme.Led | |
| 60 | + import cc.vectrum.code.theme.Mono | |
| 61 | + import cc.vectrum.code.theme.Rule | |
| 62 | + import cc.vectrum.code.theme.Segmented | |
| 63 | + import cc.vectrum.code.theme.StatusLed | |
| 64 | + import cc.vectrum.code.theme.Toggle | |
| 65 | + import cc.vectrum.code.theme.Txt | |
| 66 | + import cc.vectrum.code.theme.palette | |
| 67 | + import cc.vectrum.code.theme.raised | |
| 68 | + import cc.vectrum.code.ui.BottomSheet | |
| 69 | + import cc.vectrum.code.ui.Field | |
| 70 | + import cc.vectrum.code.ui.Lucide | |
| 71 | + import cc.vectrum.code.ui.MenuRow | |
| 72 | + import cc.vectrum.code.ui.MenuSeparator | |
| 73 | + import cc.vectrum.code.ui.PopupMenu | |
| 74 | + import cc.vectrum.code.ui.terminal.KeyInputListener | |
| 75 | + import cc.vectrum.code.ui.terminal.KeyInputView | |
| 76 | + import kotlinx.coroutines.Dispatchers | |
| 77 | + import kotlinx.coroutines.launch | |
| 78 | + import kotlinx.coroutines.withContext | |
| 79 | + import kotlinx.serialization.json.JsonObject | |
| 80 | + | |
| 81 | + /** The rail's width; the screen keeps clear of it. */ | |
| 82 | + private val RAIL_WIDTH = 56.dp | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * One open device, as the desktop's `DeviceView`: its live screen, which | |
| 86 | + * takes taps, drags and typing, and the rail of quick controls beside it, | |
| 87 | + * with the Tools drawer as a sheet. It streams for as long as it shows. | |
| 88 | + */ | |
| 89 | + @Composable | |
| 90 | + fun DeviceView( | |
| 91 | + host: HostState, | |
| 92 | + calls: DeviceCalls, | |
| 93 | + state: DeviceHubState, | |
| 94 | + device: DeviceSummary, | |
| 95 | + onClose: () -> Unit, | |
| 96 | + onPowerOff: () -> Unit, | |
| 97 | + ) { | |
| 98 | + val p = palette | |
| 99 | + val context = LocalContext.current | |
| 100 | + val scope = rememberCoroutineScope() | |
| 101 | + val stream = remember(device.id, host.saved.address, host.saved.key) { DeviceStream(host.saved.address, host.saved.key, device) } | |
| 102 | + DisposableEffect(stream) { | |
| 103 | + stream.start() | |
| 104 | + onDispose { stream.stop() } | |
| 105 | + } | |
| 106 | + | |
| 107 | + // The device's settings, read when it shows; every action reads them again. | |
| 108 | + var detail by remember(device.id) { mutableStateOf<DeviceDetail?>(null) } | |
| 109 | + var error by remember(device.id) { mutableStateOf<String?>(null) } | |
| 110 | + var pending by remember(device.id) { mutableStateOf(false) } | |
| 111 | + var shooting by remember(device.id) { mutableStateOf(false) } | |
| 112 | + var tools by remember { mutableStateOf(false) } | |
| 113 | + LaunchedEffect(device.id) { | |
| 114 | + calls.detail(device.id) | |
| 115 | + .onSuccess { | |
| 116 | + detail = it | |
| 117 | + error = null | |
| 118 | + } | |
| 119 | + .onFailure { | |
| 120 | + detail = null | |
| 121 | + error = it.message | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** Runs one action at a time; the reply replaces the settings shown, so a control never shows what the device didn't confirm. */ | |
| 126 | + fun act(action: JsonObject) { | |
| 127 | + if (pending || detail == null) return | |
| 128 | + pending = true | |
| 129 | + error = null | |
| 130 | + scope.launch { | |
| 131 | + calls.action(device.id, action) | |
| 132 | + .onSuccess { detail = it } | |
| 133 | + .onFailure { error = it.message } | |
| 134 | + pending = false | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + fun screenshot() { | |
| 139 | + if (shooting) return | |
| 140 | + shooting = true | |
| 141 | + scope.launch { | |
| 142 | + calls.screenshot(device.id) | |
| 143 | + .onSuccess { png -> runCatching { share(context, device.name, png) }.onFailure { error = it.message } } | |
| 144 | + .onFailure { error = it.message } | |
| 145 | + shooting = false | |
| 146 | + } | |
| 147 | + } | |
| 148 | + | |
| 149 | + // The keyboard types into the device. | |
| 150 | + val input = remember { KeyInputView(context) } | |
| 151 | + input.listener = object : KeyInputListener { | |
| 152 | + override fun onText(text: String, ctrl: Boolean, alt: Boolean) { | |
| 153 | + if (!ctrl && !alt) stream.text(text) | |
| 154 | + } | |
| 155 | + | |
| 156 | + override fun onKey(key: TermKey, ctrl: Boolean, alt: Boolean, shift: Boolean) { | |
| 157 | + val name = when (key) { | |
| 158 | + TermKey.Enter -> "enter" | |
| 159 | + TermKey.Backspace -> "backspace" | |
| 160 | + TermKey.Delete -> "delete" | |
| 161 | + TermKey.Tab -> "tab" | |
| 162 | + TermKey.Escape -> "escape" | |
| 163 | + TermKey.Up -> "up" | |
| 164 | + TermKey.Down -> "down" | |
| 165 | + TermKey.Left -> "left" | |
| 166 | + TermKey.Right -> "right" | |
| 167 | + TermKey.Home -> "home" | |
| 168 | + TermKey.End -> "end" | |
| 169 | + TermKey.PageUp -> "pageup" | |
| 170 | + TermKey.PageDown -> "pagedown" | |
| 171 | + else -> return | |
| 172 | + } | |
| 173 | + stream.key(name) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + DisposableEffect(input) { onDispose { input.hide() } } | |
| 177 | + | |
| 178 | + val controlsDisabled = pending || detail == null | |
| 179 | + Column(Modifier.fillMaxSize().background(p.s)) { | |
| 180 | + val shown = error | |
| 181 | + if (shown != null && !tools) Txt(shown, Modifier.padding(horizontal = 12.dp, vertical = 6.dp), color = p.red, size = 11.5.sp) | |
| 182 | + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { | |
| 183 | + AndroidView(factory = { input }, modifier = Modifier.size(1.dp).alpha(0f)) | |
| 184 | + val width = (maxWidth - RAIL_WIDTH - 16.dp).coerceAtLeast(0.dp) | |
| 185 | + val height = (maxHeight - 16.dp).coerceAtLeast(0.dp) | |
| 186 | + val aspect = stream.aspect | |
| 187 | + val (frameWidth, frameHeight) = if (width / height.coerceAtLeast(1.dp) > aspect) height * aspect to height else width to width / aspect | |
| 188 | + Box(Modifier.fillMaxSize().padding(end = RAIL_WIDTH), contentAlignment = Alignment.Center) { | |
| 189 | + Screen(stream, Modifier.width(frameWidth).height(frameHeight)) | |
| 190 | + if (stream.status == StreamStatus.Streaming) { | |
| 191 | + if (!stream.inputConnected) { | |
| 192 | + val why = stream.inputDetail?.let { " ($it)" }.orEmpty() | |
| 193 | + Txt( | |
| 194 | + "Input disconnected$why, reconnecting…", | |
| 195 | + Modifier | |
| 196 | + .align(Alignment.BottomCenter) | |
| 197 | + .padding(bottom = 8.dp) | |
| 198 | + .background(p.s.copy(alpha = 0.9f), RoundedCornerShape(6.dp)) | |
| 199 | + .padding(horizontal = 8.dp, vertical = 4.dp), | |
| 200 | + color = p.ink3, | |
| 201 | + size = 11.5.sp, | |
| 202 | + ) | |
| 203 | + } | |
| 204 | + } else { | |
| 205 | + val failed = stream.status == StreamStatus.Error | |
| 206 | + Box(Modifier.fillMaxSize().background(p.s)) { | |
| 207 | + LoadingView( | |
| 208 | + device.name, | |
| 209 | + "${state.hostLabel(device.hostId)} · ${device.version}", | |
| 210 | + if (failed) stream.detail ?: "Stream failed." else "Connecting video…", | |
| 211 | + error = failed, | |
| 212 | + ) { | |
| 213 | + if (failed) Cap(label = "Reconnect", size = CapSize.Compact) { stream.restart() } | |
| 214 | + } | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + Rail( | |
| 219 | + Modifier.align(Alignment.CenterEnd).padding(end = 8.dp), | |
| 220 | + stream = stream, | |
| 221 | + detail = detail, | |
| 222 | + controlsDisabled = controlsDisabled, | |
| 223 | + tools = tools, | |
| 224 | + shooting = shooting, | |
| 225 | + onAct = ::act, | |
| 226 | + onKeyboard = { input.show() }, | |
| 227 | + onTools = { tools = !tools }, | |
| 228 | + onScreenshot = ::screenshot, | |
| 229 | + onClose = onClose, | |
| 230 | + onPowerOff = onPowerOff, | |
| 231 | + ) | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + BottomSheet(open = tools, onDismiss = { tools = false }, title = "Tools") { | |
| 236 | + Tools(device, state, detail, error, pending, controlsDisabled, ::act) | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** The live screen: video on a TextureView (Android) or the newest frame (iOS), and touches back. */ | |
| 241 | + @Composable | |
| 242 | + private fun Screen(stream: DeviceStream, modifier: Modifier) { | |
| 243 | + Box(modifier.clip(RoundedCornerShape(14.dp)).background(Color.Black)) { | |
| 244 | + if (stream.device.ios) { | |
| 245 | + stream.frame?.let { Image(it, contentDescription = null, Modifier.fillMaxSize(), contentScale = ContentScale.FillBounds) } | |
| 246 | + } else { | |
| 247 | + AndroidView( | |
| 248 | + factory = { context -> | |
| 249 | + TextureView(context).apply { | |
| 250 | + surfaceTextureListener = object : TextureView.SurfaceTextureListener { | |
| 251 | + private var surface: Surface? = null | |
| 252 | + | |
| 253 | + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { | |
| 254 | + surface = Surface(texture).also(stream::setSurface) | |
| 255 | + } | |
| 256 | + | |
| 257 | + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {} | |
| 258 | + | |
| 259 | + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { | |
| 260 | + stream.setSurface(null) | |
| 261 | + surface?.release() | |
| 262 | + surface = null | |
| 263 | + return true | |
| 264 | + } | |
| 265 | + | |
| 266 | + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {} | |
| 267 | + } | |
| 268 | + } | |
| 269 | + }, | |
| 270 | + modifier = Modifier.fillMaxSize(), | |
| 271 | + ) | |
| 272 | + } | |
| 273 | + Box( | |
| 274 | + Modifier | |
| 275 | + .fillMaxSize() | |
| 276 | + .pointerInput(stream) { | |
| 277 | + awaitEachGesture { | |
| 278 | + val down = awaitFirstDown() | |
| 279 | + down.consume() | |
| 280 | + val w = size.width.toFloat().coerceAtLeast(1f) | |
| 281 | + val h = size.height.toFloat().coerceAtLeast(1f) | |
| 282 | + stream.touch(TouchPhase.Begin, down.position.x / w, down.position.y / h) | |
| 283 | + var last = down.position | |
| 284 | + while (true) { | |
| 285 | + val event = awaitPointerEvent() | |
| 286 | + val change = event.changes.firstOrNull { it.id == down.id } ?: break | |
| 287 | + if (!change.pressed) { | |
| 288 | + stream.touch(TouchPhase.End, change.position.x / w, change.position.y / h) | |
| 289 | + break | |
| 290 | + } | |
| 291 | + if (change.positionChange() != androidx.compose.ui.geometry.Offset.Zero) { | |
| 292 | + change.consume() | |
| 293 | + last = change.position | |
| 294 | + stream.touch(TouchPhase.Move, last.x / w, last.y / h) | |
| 295 | + } | |
| 296 | + } | |
| 297 | + } | |
| 298 | + }, | |
| 299 | + ) | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + /** The rail of quick controls, as a raised pill beside the screen. */ | |
| 304 | + @Composable | |
| 305 | + private fun Rail( | |
| 306 | + modifier: Modifier, | |
| 307 | + stream: DeviceStream, | |
| 308 | + detail: DeviceDetail?, | |
| 309 | + controlsDisabled: Boolean, | |
| 310 | + tools: Boolean, | |
| 311 | + shooting: Boolean, | |
| 312 | + onAct: (JsonObject) -> Unit, | |
| 313 | + onKeyboard: () -> Unit, | |
| 314 | + onTools: () -> Unit, | |
| 315 | + onScreenshot: () -> Unit, | |
| 316 | + onClose: () -> Unit, | |
| 317 | + onPowerOff: () -> Unit, | |
| 318 | + ) { | |
| 319 | + val p = palette | |
| 320 | + val inputDisabled = !stream.inputConnected | |
| 321 | + val dark = detail?.settings?.dark | |
| 322 | + val textSize = detail?.settings?.textSize | |
| 323 | + var rotateMenu by remember { mutableStateOf(false) } | |
| 324 | + var textMenu by remember { mutableStateOf(false) } | |
| 325 | + var moreMenu by remember { mutableStateOf(false) } | |
| 326 | + Column( | |
| 327 | + modifier.raised(p, RoundedCornerShape(50)).verticalScroll(rememberScrollState()).padding(6.dp), | |
| 328 | + verticalArrangement = Arrangement.spacedBy(3.dp), | |
| 329 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 330 | + ) { | |
| 331 | + RailButton(Lucide.House, enabled = !inputDisabled) { stream.button(DeviceButton.Home) } | |
| 332 | + if (!stream.device.ios) { | |
| 333 | + RailButton(Lucide.ChevronLeft, enabled = !inputDisabled) { stream.button(DeviceButton.Back) } | |
| 334 | + RailButton(Lucide.Square, enabled = !inputDisabled) { stream.button(DeviceButton.Recents) } | |
| 335 | + Box { | |
| 336 | + RailButton(Lucide.RotateCcw, enabled = !controlsDisabled) { rotateMenu = true } | |
| 337 | + PopupMenu(open = rotateMenu, onDismiss = { rotateMenu = false }, alignment = Alignment.TopEnd, minWidth = 160.dp) { | |
| 338 | + MenuRow("Portrait") { | |
| 339 | + rotateMenu = false | |
| 340 | + onAct(DeviceAction.orientation("Portrait")) | |
| 341 | + } | |
| 342 | + MenuRow("Landscape") { | |
| 343 | + rotateMenu = false | |
| 344 | + onAct(DeviceAction.orientation("LandscapeLeft")) | |
| 345 | + } | |
| 346 | + } | |
| 347 | + } | |
| 348 | + } else { | |
| 349 | + RailButton(Lucide.RotateCcw, enabled = !inputDisabled) { stream.rotate() } | |
| 350 | + } | |
| 351 | + Spacer(Modifier.padding(vertical = 3.dp).height(1.dp).width(18.dp).background(p.rule)) | |
| 352 | + RailButton(if (dark == true) Lucide.Sun else Lucide.Moon, enabled = !controlsDisabled && dark != null) { | |
| 353 | + onAct(DeviceAction.appearance(dark != true)) | |
| 354 | + } | |
| 355 | + Box { | |
| 356 | + RailButton(Lucide.Type, enabled = !controlsDisabled && textSize != null) { textMenu = true } | |
| 357 | + PopupMenu(open = textMenu, onDismiss = { textMenu = false }, alignment = Alignment.TopEnd, minWidth = 160.dp) { | |
| 358 | + for ((value, label) in TEXT_SIZES) { | |
| 359 | + MenuRow(label, selected = textSize == value) { | |
| 360 | + textMenu = false | |
| 361 | + onAct(DeviceAction.textSize(value)) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + } | |
| 365 | + } | |
| 366 | + RailButton(Lucide.Keyboard, enabled = !inputDisabled, onClick = onKeyboard) | |
| 367 | + RailButton(Lucide.SlidersHorizontal, selected = tools, onClick = onTools) | |
| 368 | + RailButton(Lucide.Camera, enabled = stream.status == StreamStatus.Streaming && !shooting, onClick = onScreenshot) | |
| 369 | + Box { | |
| 370 | + RailButton(Lucide.Ellipsis) { moreMenu = true } | |
| 371 | + PopupMenu(open = moreMenu, onDismiss = { moreMenu = false }, alignment = Alignment.BottomEnd, minWidth = 200.dp) { | |
| 372 | + MenuRow("Close device panel", icon = Lucide.X) { | |
| 373 | + moreMenu = false | |
| 374 | + onClose() | |
| 375 | + } | |
| 376 | + MenuSeparator() | |
| 377 | + MenuRow("Power off device", icon = Lucide.Power) { | |
| 378 | + moreMenu = false | |
| 379 | + onPowerOff() | |
| 380 | + } | |
| 381 | + } | |
| 382 | + } | |
| 383 | + } | |
| 384 | + } | |
| 385 | + | |
| 386 | + @Composable | |
| 387 | + private fun RailButton(icon: Lucide, enabled: Boolean = true, selected: Boolean = false, onClick: () -> Unit) = | |
| 388 | + Cap(icon = icon, kind = CapKind.Ghost, size = CapSize.Mini, enabled = enabled, selected = selected, onClick = onClick) | |
| 389 | + | |
| 390 | + /** The Tools drawer's controls the protocol carries: the foreground app, appearance, text size, orientation and settings. */ | |
| 391 | + @Composable | |
| 392 | + private fun ColumnScope.Tools( | |
| 393 | + device: DeviceSummary, | |
| 394 | + state: DeviceHubState, | |
| 395 | + detail: DeviceDetail?, | |
| 396 | + error: String?, | |
| 397 | + pending: Boolean, | |
| 398 | + disabled: Boolean, | |
| 399 | + onAct: (JsonObject) -> Unit, | |
| 400 | + ) { | |
| 401 | + val p = palette | |
| 402 | + val settings = detail?.settings ?: DeviceSettings() | |
| 403 | + val foreground = detail?.foreground | |
| 404 | + if (pending) { | |
| 405 | + Row(Modifier.padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 406 | + StatusLed(Led.Waiting, pulse = true) | |
| 407 | + Txt("Working…", color = p.ink3, size = 11.5.sp) | |
| 408 | + } | |
| 409 | + } | |
| 410 | + if (error != null) { | |
| 411 | + Txt(error, Modifier.fillMaxWidth().background(p.red.copy(alpha = 0.1f)).padding(horizontal = 12.dp, vertical = 8.dp), color = p.red, size = 11.5.sp) | |
| 412 | + } | |
| 413 | + if (detail == null && error == null) { | |
| 414 | + Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 415 | + StatusLed(Led.Waiting, pulse = true) | |
| 416 | + Txt("Reading device settings…", color = p.ink3, size = 11.5.sp) | |
| 417 | + } | |
| 418 | + } | |
| 419 | + val diagnostics = state.hostDetail | |
| 420 | + if (state.ready && diagnostics != null) { | |
| 421 | + Section("Host diagnostics") { Txt(diagnostics, color = p.ink3, size = 11.5.sp, lineHeight = 16.sp) } | |
| 422 | + } | |
| 423 | + | |
| 424 | + Section("App") { | |
| 425 | + ToolRow("Foreground") { Txt(foreground?.id ?: "—", color = p.ink, size = 11.5.sp, family = Mono, maxLines = 1) } | |
| 426 | + if (foreground != null) { | |
| 427 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 428 | + Cap(label = "Terminate", size = CapSize.Compact, enabled = !disabled) { onAct(DeviceAction.terminate(foreground.id)) } | |
| 429 | + Cap(label = "Relaunch", size = CapSize.Compact, enabled = !disabled) { onAct(DeviceAction.launch(foreground.id)) } | |
| 430 | + } | |
| 431 | + } | |
| 432 | + SubmitRow("https://… or myapp://", "Open", disabled) { onAct(DeviceAction.openUrl(it)) } | |
| 433 | + SubmitRow(if (device.ios) "Bundle ID to launch" else "Package name to launch", "Launch", disabled) { onAct(DeviceAction.launch(it)) } | |
| 434 | + } | |
| 435 | + | |
| 436 | + Section(if (device.ios) "Simulator" else "Emulator") { | |
| 437 | + ToolRow("Appearance") { | |
| 438 | + Segmented(listOf<Pair<Boolean?, String>>(false to "Light", true to "Dark"), settings.dark, Modifier.alpha(if (disabled) 0.5f else 1f)) { dark -> | |
| 439 | + if (!disabled && dark != null) onAct(DeviceAction.appearance(dark)) | |
| 440 | + } | |
| 441 | + } | |
| 442 | + ToolRow("Text size") { | |
| 443 | + Choice( | |
| 444 | + current = TEXT_SIZES.find { it.first == settings.textSize }?.second ?: "Unknown", | |
| 445 | + options = TEXT_SIZES, | |
| 446 | + selected = settings.textSize, | |
| 447 | + enabled = !disabled, | |
| 448 | + ) { onAct(DeviceAction.textSize(it)) } | |
| 449 | + } | |
| 450 | + if (!device.ios) { | |
| 451 | + ToolRow("Orientation") { | |
| 452 | + Choice(current = "Rotate to…", options = ORIENTATIONS, selected = null, enabled = !disabled) { onAct(DeviceAction.orientation(it)) } | |
| 453 | + } | |
| 454 | + } | |
| 455 | + val toggles = if (device.ios) { | |
| 456 | + listOf( | |
| 457 | + "ReduceMotion" to "Reduce Motion", | |
| 458 | + "IncreaseContrast" to "Increase Contrast", | |
| 459 | + "ReduceTransparency" to "Reduce Transparency", | |
| 460 | + "ShowBorders" to "Show Borders", | |
| 461 | + "VoiceOver" to "VoiceOver", | |
| 462 | + ) | |
| 463 | + } else { | |
| 464 | + listOf("ReduceMotion" to "Reduce Motion", "NetworkEnabled" to "Network") | |
| 465 | + } | |
| 466 | + for ((setting, label) in toggles) { | |
| 467 | + val value = settings.toggle(setting) | |
| 468 | + val blocked = disabled || value == null | |
| 469 | + ToolRow(label) { | |
| 470 | + Toggle(value ?: false, Modifier.alpha(if (blocked) 0.5f else 1f), enabled = !blocked) { on -> | |
| 471 | + onAct(DeviceAction.toggle(setting, on)) | |
| 472 | + } | |
| 473 | + } | |
| 474 | + } | |
| 475 | + } | |
| 476 | + } | |
| 477 | + | |
| 478 | + @Composable | |
| 479 | + private fun Section(title: String, content: @Composable ColumnScope.() -> Unit) { | |
| 480 | + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 481 | + Txt(title, color = palette.ink3, size = 11.5.sp, weight = FontWeight.Medium) | |
| 482 | + content() | |
| 483 | + } | |
| 484 | + Rule() | |
| 485 | + } | |
| 486 | + | |
| 487 | + @Composable | |
| 488 | + private fun ToolRow(label: String, control: @Composable () -> Unit) { | |
| 489 | + Row( | |
| 490 | + Modifier.fillMaxWidth().heightIn(min = 26.dp), | |
| 491 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 492 | + verticalAlignment = Alignment.CenterVertically, | |
| 493 | + ) { | |
| 494 | + Txt(label, color = palette.ink3, size = 11.5.sp, maxLines = 1) | |
| 495 | + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { control() } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** A text field with its button, which runs the action on its text. */ | |
| 500 | + @Composable | |
| 501 | + private fun SubmitRow(placeholder: String, label: String, disabled: Boolean, onSubmit: (String) -> Unit) { | |
| 502 | + var value by remember { mutableStateOf(TextFieldValue("")) } | |
| 503 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 504 | + Field(value, { value = it }, Modifier.weight(1f), placeholder = placeholder) | |
| 505 | + Cap(label = label, size = CapSize.Compact, enabled = !disabled && value.text.isNotBlank()) { | |
| 506 | + onSubmit(value.text.trim()) | |
| 507 | + value = TextFieldValue("") | |
| 508 | + } | |
| 509 | + } | |
| 510 | + } | |
| 511 | + | |
| 512 | + /** A dropdown of labelled options, by value. */ | |
| 513 | + @Composable | |
| 514 | + private fun Choice(current: String, options: List<Pair<String, String>>, selected: String?, enabled: Boolean, onPick: (String) -> Unit) { | |
| 515 | + var open by remember { mutableStateOf(false) } | |
| 516 | + Box { | |
| 517 | + Cap(label = current, size = CapSize.Compact, caret = true, enabled = enabled) { open = true } | |
| 518 | + PopupMenu(open = open, onDismiss = { open = false }, alignment = Alignment.TopEnd, minWidth = 160.dp) { | |
| 519 | + for ((value, label) in options) { | |
| 520 | + MenuRow(label, selected = value == selected) { | |
| 521 | + open = false | |
| 522 | + onPick(value) | |
| 523 | + } | |
| 524 | + } | |
| 525 | + } | |
| 526 | + } | |
| 527 | + } | |
| 528 | + | |
| 529 | + /** Saves a screenshot to Pictures/Vectrum Code and offers it to the share sheet. */ | |
| 530 | + private suspend fun share(context: Context, name: String, png: ByteArray) { | |
| 531 | + val safe = name.map { if (it.isLetterOrDigit() || it == '-') it else '-' }.joinToString("") | |
| 532 | + val file = "$safe-${System.currentTimeMillis()}.png" | |
| 533 | + val uri: Uri = withContext(Dispatchers.IO) { | |
| 534 | + val values = ContentValues().apply { | |
| 535 | + put(MediaStore.Images.Media.DISPLAY_NAME, file) | |
| 536 | + put(MediaStore.Images.Media.MIME_TYPE, "image/png") | |
| 537 | + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Vectrum Code") | |
| 538 | + } | |
| 539 | + val resolver = context.contentResolver | |
| 540 | + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: error("Couldn't save the screenshot.") | |
| 541 | + resolver.openOutputStream(uri)?.use { it.write(png) } ?: error("Couldn't save the screenshot.") | |
| 542 | + uri | |
| 543 | + } | |
| 544 | + val send = Intent(Intent.ACTION_SEND) | |
| 545 | + .setType("image/png") | |
| 546 | + .putExtra(Intent.EXTRA_STREAM, uri) | |
| 547 | + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | |
| 548 | + context.startActivity(Intent.createChooser(send, "Share screenshot").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) | |
| 549 | + } | |
| @@ -0,0 +1,120 @@ | ||
| 1 | + package cc.vectrum.code.ui.device | |
| 2 | + | |
| 3 | + import androidx.compose.runtime.getValue | |
| 4 | + import androidx.compose.runtime.mutableLongStateOf | |
| 5 | + import androidx.compose.runtime.mutableStateListOf | |
| 6 | + import androidx.compose.runtime.mutableStateOf | |
| 7 | + import androidx.compose.runtime.setValue | |
| 8 | + import cc.vectrum.code.data.HostState | |
| 9 | + import cc.vectrum.code.data.ThreadRef | |
| 10 | + import kotlinx.coroutines.MainScope | |
| 11 | + import kotlinx.serialization.json.decodeFromJsonElement | |
| 12 | + | |
| 13 | + /** One tab of a Device panel: a device, or the list to pick one from. */ | |
| 14 | + class DeviceTab(val id: Long, device: DeviceSummary? = null) { | |
| 15 | + /** A name the user gave the tab. */ | |
| 16 | + var name by mutableStateOf<String?>(null) | |
| 17 | + var device by mutableStateOf(device) | |
| 18 | + | |
| 19 | + val title: String get() = name ?: device?.name ?: "Device" | |
| 20 | + } | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * A thread's Device panel, as the desktop's `DevicePanel` keeps it: its | |
| 24 | + * tabs, the device being opened and the last error. It lives here rather | |
| 25 | + * than in the panel, so the tabs are there when the panel opens again; | |
| 26 | + * streams only run while it shows. | |
| 27 | + */ | |
| 28 | + class ThreadDevices internal constructor(val ref: ThreadRef) { | |
| 29 | + val tabs = mutableStateListOf(DeviceTab(1)) | |
| 30 | + var active by mutableLongStateOf(1) | |
| 31 | + private var nextId = 2L | |
| 32 | + | |
| 33 | + /** The device being opened from the list. */ | |
| 34 | + var pending by mutableStateOf<DeviceSummary?>(null) | |
| 35 | + var error by mutableStateOf<String?>(null) | |
| 36 | + | |
| 37 | + val activeTab: DeviceTab? get() = tabs.find { it.id == active } | |
| 38 | + | |
| 39 | + /** Shows a device: in its tab if it has one, else in the empty tab showing, else in a new tab. */ | |
| 40 | + fun show(device: DeviceSummary) { | |
| 41 | + val existing = tabs.find { it.device?.id == device.id } | |
| 42 | + if (existing != null) { | |
| 43 | + existing.device = device | |
| 44 | + active = existing.id | |
| 45 | + return | |
| 46 | + } | |
| 47 | + val empty = activeTab?.takeIf { it.device == null } | |
| 48 | + if (empty != null) { | |
| 49 | + empty.device = device | |
| 50 | + return | |
| 51 | + } | |
| 52 | + val tab = DeviceTab(nextId++, device) | |
| 53 | + tabs.add(tab) | |
| 54 | + active = tab.id | |
| 55 | + } | |
| 56 | + | |
| 57 | + fun newTab() { | |
| 58 | + val tab = DeviceTab(nextId++) | |
| 59 | + tabs.add(tab) | |
| 60 | + active = tab.id | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** Takes a tab out; returns whether it was the last, so the panel closes. */ | |
| 64 | + fun remove(id: Long): Boolean { | |
| 65 | + val ix = tabs.indexOfFirst { it.id == id } | |
| 66 | + if (ix < 0) return false | |
| 67 | + tabs.removeAt(ix) | |
| 68 | + if (tabs.isEmpty()) { | |
| 69 | + val tab = DeviceTab(nextId++) | |
| 70 | + tabs.add(tab) | |
| 71 | + active = tab.id | |
| 72 | + return true | |
| 73 | + } | |
| 74 | + if (active == id) active = tabs[ix.coerceAtMost(tabs.size - 1)].id | |
| 75 | + return false | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** Drops the tabs showing a device (all of them for `null`), as when an agent closes it. */ | |
| 79 | + fun forget(device: String?) { | |
| 80 | + tabs.filter { tab -> tab.device != null && (device == null || tab.device?.id == device) }.forEach { remove(it.id) } | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** Keeps open tabs' devices current (an AVD becomes a serial when it boots). */ | |
| 84 | + fun sync(state: DeviceHubState) { | |
| 85 | + for (tab in tabs) { | |
| 86 | + val current = tab.device ?: continue | |
| 87 | + val fresh = state.device(current.hostId, current.id) ?: continue | |
| 88 | + if (fresh != current) tab.device = fresh | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** Every thread's Device panel, and the devices agents open in them. */ | |
| 94 | + object DeviceRegistry { | |
| 95 | + private val threads = HashMap<ThreadRef, ThreadDevices>() | |
| 96 | + private val listening = HashSet<String>() | |
| 97 | + | |
| 98 | + /** For work that outlives the panel, such as starting a device. */ | |
| 99 | + val scope = MainScope() | |
| 100 | + | |
| 101 | + fun of(host: HostState, ref: ThreadRef): ThreadDevices { | |
| 102 | + listen(host) | |
| 103 | + return threads.getOrPut(ref) { ThreadDevices(ref) } | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** Devices an agent on the host opens or closes show up in, or leave, the thread's panel. */ | |
| 107 | + private fun listen(host: HostState) { | |
| 108 | + val id = host.saved.id | |
| 109 | + if (!listening.add(id)) return | |
| 110 | + host.deviceEvents.add { thread, device, closed -> | |
| 111 | + val ref = ThreadRef(id, thread) | |
| 112 | + if (device != null) { | |
| 113 | + val summary = runCatching { DeviceJson.decodeFromJsonElement<DeviceSummary>(device) }.getOrNull() | |
| 114 | + if (summary != null) threads.getOrPut(ref) { ThreadDevices(ref) }.show(summary) | |
| 115 | + } else if (closed != null) { | |
| 116 | + threads[ref]?.forget(closed.ifEmpty { null }) | |
| 117 | + } | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| @@ -0,0 +1,272 @@ | ||
| 1 | + package cc.vectrum.code.ui.device | |
| 2 | + | |
| 3 | + import cc.vectrum.code.data.HostState | |
| 4 | + import cc.vectrum.code.net.ClientMessage | |
| 5 | + import kotlinx.coroutines.withTimeoutOrNull | |
| 6 | + import kotlinx.serialization.SerialName | |
| 7 | + import kotlinx.serialization.Serializable | |
| 8 | + import kotlinx.serialization.json.Json | |
| 9 | + import kotlinx.serialization.json.JsonArray | |
| 10 | + import kotlinx.serialization.json.JsonElement | |
| 11 | + import kotlinx.serialization.json.JsonNull | |
| 12 | + import kotlinx.serialization.json.JsonObject | |
| 13 | + import kotlinx.serialization.json.JsonPrimitive | |
| 14 | + import kotlinx.serialization.json.decodeFromJsonElement | |
| 15 | + | |
| 16 | + // The host's device state and calls, as src/device/mod.rs serializes them: | |
| 17 | + // `State` in snake_case, device summaries and sessions in camelCase, enums | |
| 18 | + // in lowercase or by their Rust names. They come raw from `HostState` | |
| 19 | + // (`deviceState`, `device(call)`) and are read here. | |
| 20 | + | |
| 21 | + /** Reads the device JSON with the field names it actually has. */ | |
| 22 | + val DeviceJson = Json { | |
| 23 | + ignoreUnknownKeys = true | |
| 24 | + coerceInputValues = true | |
| 25 | + explicitNulls = false | |
| 26 | + } | |
| 27 | + | |
| 28 | + /** The machine the host's devices run on, as the host names it. */ | |
| 29 | + const val LOCAL_HOST = "local" | |
| 30 | + | |
| 31 | + @Serializable | |
| 32 | + data class DeviceSummary( | |
| 33 | + val hostId: String = "", | |
| 34 | + /** Simulator udid or adb serial (an AVD's name while it isn't running). */ | |
| 35 | + val id: String = "", | |
| 36 | + /** `ios` or `android`. */ | |
| 37 | + val platform: String = "android", | |
| 38 | + val name: String = "", | |
| 39 | + val version: String = "", | |
| 40 | + val booted: Boolean = false, | |
| 41 | + val physical: Boolean = false, | |
| 42 | + ) { | |
| 43 | + val ios: Boolean get() = platform == "ios" | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Serializable | |
| 47 | + data class PlatformAvailability(val platform: String = "", val available: Boolean = false, val reason: String? = null) | |
| 48 | + | |
| 49 | + @Serializable | |
| 50 | + data class HostSummary( | |
| 51 | + val id: String = "", | |
| 52 | + val kind: String = "", | |
| 53 | + val label: String = "", | |
| 54 | + val platforms: List<PlatformAvailability> = emptyList(), | |
| 55 | + val hubInstalled: Boolean = false, | |
| 56 | + val agentDeviceInstalled: Boolean = false, | |
| 57 | + ) | |
| 58 | + | |
| 59 | + @Serializable | |
| 60 | + data class DeviceSession( | |
| 61 | + val threadId: String = "", | |
| 62 | + val hostId: String = "", | |
| 63 | + val deviceId: String = "", | |
| 64 | + val platform: String = "android", | |
| 65 | + val openedAt: String = "", | |
| 66 | + ) | |
| 67 | + | |
| 68 | + /** Everything the Device panel renders from: the host's `device::State`. */ | |
| 69 | + @Serializable | |
| 70 | + data class DeviceHubState( | |
| 71 | + val enabled: Boolean = false, | |
| 72 | + @SerialName("agent_access") val agentAccess: Boolean = false, | |
| 73 | + @SerialName("onboarding_completed") val onboardingCompleted: Boolean = false, | |
| 74 | + /** `disabled`, `idle`, `installing`, `starting`, `ready` or `failed`. */ | |
| 75 | + @SerialName("host_status") val hostStatus: String = "disabled", | |
| 76 | + @SerialName("host_detail") val hostDetail: String? = null, | |
| 77 | + val hosts: List<HostSummary> = emptyList(), | |
| 78 | + val devices: List<DeviceSummary> = emptyList(), | |
| 79 | + val sessions: List<DeviceSession> = emptyList(), | |
| 80 | + /** Devices starting up, with the thread that asked: `[summary, thread]` pairs. */ | |
| 81 | + val booting: List<JsonElement> = emptyList(), | |
| 82 | + val revision: Long = 0, | |
| 83 | + ) { | |
| 84 | + val disabled: Boolean get() = hostStatus == "disabled" | |
| 85 | + val ready: Boolean get() = hostStatus == "ready" | |
| 86 | + val failed: Boolean get() = hostStatus == "failed" | |
| 87 | + val busy: Boolean get() = hostStatus == "installing" || hostStatus == "starting" | |
| 88 | + | |
| 89 | + /** Setup still has to run before anything installs or starts. */ | |
| 90 | + val needsSetup: Boolean get() = disabled || !onboardingCompleted | |
| 91 | + | |
| 92 | + fun device(host: String, id: String) = devices.find { it.hostId == host && it.id == id } | |
| 93 | + | |
| 94 | + fun hostLabel(host: String) = hosts.find { it.id == host }?.label ?: "Device host" | |
| 95 | + | |
| 96 | + /** The names of the devices a thread is starting. */ | |
| 97 | + fun booting(thread: String): List<String> = booting.mapNotNull { pair -> | |
| 98 | + val items = pair as? JsonArray ?: return@mapNotNull null | |
| 99 | + val asked = (items.getOrNull(1) as? JsonPrimitive)?.content | |
| 100 | + if (asked != thread) return@mapNotNull null | |
| 101 | + runCatching { DeviceJson.decodeFromJsonElement<DeviceSummary>(items[0]).name }.getOrNull() | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** The panel's "iOS"/"Android" lines: ready, or what's missing. */ | |
| 105 | + fun platformStatus(platform: String): Pair<Boolean, String> { | |
| 106 | + val label = platformLabel(platform) | |
| 107 | + val availability = hosts.flatMap { it.platforms }.find { it.platform == platform } | |
| 108 | + if (availability == null || !availability.available) { | |
| 109 | + return false to (availability?.reason ?: "$label support was not detected.") | |
| 110 | + } | |
| 111 | + if (ready && devices.none { it.platform == platform }) { | |
| 112 | + return false to if (platform == "ios") { | |
| 113 | + "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components." | |
| 114 | + } else { | |
| 115 | + "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager." | |
| 116 | + } | |
| 117 | + } | |
| 118 | + return true to if (platform == "ios") "Xcode and iOS Simulator are available." else "The Android SDK and Emulator are available." | |
| 119 | + } | |
| 120 | + | |
| 121 | + companion object { | |
| 122 | + /** Another device's devices read as starting up until it has said. */ | |
| 123 | + val Unheard = DeviceHubState(enabled = true, onboardingCompleted = true, hostStatus = "starting") | |
| 124 | + | |
| 125 | + /** The state a host sent, with its "This machine" called by the host's name. */ | |
| 126 | + fun read(json: JsonObject?, hostName: String): DeviceHubState { | |
| 127 | + if (json == null) return Unheard | |
| 128 | + val state = runCatching { DeviceJson.decodeFromJsonElement<DeviceHubState>(json) }.getOrNull() ?: return Unheard | |
| 129 | + return state.copy(hosts = state.hosts.map { if (it.id == LOCAL_HOST) it.copy(label = hostName) else it }) | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + fun platformLabel(platform: String) = if (platform == "ios") "iOS" else "Android" | |
| 135 | + | |
| 136 | + /** The device list's heading for a platform. */ | |
| 137 | + fun platformGroup(platform: String) = if (platform == "ios") "iOS Simulators" else "Android Emulators" | |
| 138 | + | |
| 139 | + @Serializable | |
| 140 | + data class DeviceSettings( | |
| 141 | + val dark: Boolean? = null, | |
| 142 | + /** `Small`, `Default`, `Large` or `ExtraLarge`. */ | |
| 143 | + @SerialName("text_size") val textSize: String? = null, | |
| 144 | + @SerialName("reduce_motion") val reduceMotion: Boolean? = null, | |
| 145 | + @SerialName("increase_contrast") val increaseContrast: Boolean? = null, | |
| 146 | + @SerialName("reduce_transparency") val reduceTransparency: Boolean? = null, | |
| 147 | + @SerialName("show_borders") val showBorders: Boolean? = null, | |
| 148 | + @SerialName("voice_over") val voiceOver: Boolean? = null, | |
| 149 | + @SerialName("network_enabled") val networkEnabled: Boolean? = null, | |
| 150 | + ) { | |
| 151 | + fun toggle(name: String): Boolean? = when (name) { | |
| 152 | + "ReduceMotion" -> reduceMotion | |
| 153 | + "IncreaseContrast" -> increaseContrast | |
| 154 | + "ReduceTransparency" -> reduceTransparency | |
| 155 | + "ShowBorders" -> showBorders | |
| 156 | + "VoiceOver" -> voiceOver | |
| 157 | + "NetworkEnabled" -> networkEnabled | |
| 158 | + else -> null | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + @Serializable | |
| 163 | + data class ForegroundApp(val id: String = "") | |
| 164 | + | |
| 165 | + /** One device's settings and foreground app, as last read. */ | |
| 166 | + @Serializable | |
| 167 | + data class DeviceDetail(val settings: DeviceSettings = DeviceSettings(), val foreground: ForegroundApp? = null) | |
| 168 | + | |
| 169 | + /** `TextSize`, by its Rust name, with its label. */ | |
| 170 | + val TEXT_SIZES = listOf("Small" to "Small", "Default" to "Default", "Large" to "Large", "ExtraLarge" to "Extra large") | |
| 171 | + | |
| 172 | + /** `Orientation`, by its Rust name, with its label. */ | |
| 173 | + val ORIENTATIONS = listOf( | |
| 174 | + "Portrait" to "Portrait", | |
| 175 | + "LandscapeLeft" to "Landscape left", | |
| 176 | + "PortraitUpsideDown" to "Upside down", | |
| 177 | + "LandscapeRight" to "Landscape right", | |
| 178 | + ) | |
| 179 | + | |
| 180 | + /** The device `Action`s the panel uses, as serde's externally tagged enum writes them. */ | |
| 181 | + object DeviceAction { | |
| 182 | + fun appearance(dark: Boolean) = tagged("set_appearance", JsonPrimitive(dark)) | |
| 183 | + | |
| 184 | + fun textSize(size: String) = tagged("set_text_size", JsonPrimitive(size)) | |
| 185 | + | |
| 186 | + fun orientation(orientation: String) = tagged("set_orientation", JsonPrimitive(orientation)) | |
| 187 | + | |
| 188 | + fun toggle(setting: String, value: Boolean) = | |
| 189 | + tagged("set_toggle", JsonObject(mapOf("setting" to JsonPrimitive(setting), "value" to JsonPrimitive(value)))) | |
| 190 | + | |
| 191 | + fun openUrl(url: String) = tagged("open_url", JsonPrimitive(url)) | |
| 192 | + | |
| 193 | + fun launch(app: String) = tagged("launch_app", JsonPrimitive(app)) | |
| 194 | + | |
| 195 | + fun terminate(app: String) = tagged("terminate_app", JsonPrimitive(app)) | |
| 196 | + | |
| 197 | + private fun tagged(name: String, value: JsonElement) = JsonObject(mapOf(name to value)) | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 201 | + * The host's devices, reached through its remote connection: `DeviceCall`s | |
| 202 | + * and their replies. Calls wait as long as the desktop's do (booting takes | |
| 203 | + * the longest). | |
| 204 | + */ | |
| 205 | + class DeviceCalls(val host: HostState) { | |
| 206 | + /** Asks the host to send its device state now and on every change. It never replies to this one. */ | |
| 207 | + fun watch() { | |
| 208 | + host.send(ClientMessage.Device(0, host.deviceCall("watch"))) | |
| 209 | + } | |
| 210 | + | |
| 211 | + suspend fun call(name: String, vararg fields: Pair<String, JsonElement>): Result<JsonElement> { | |
| 212 | + val reply = withTimeoutOrNull(CALL_TIMEOUT) { host.device(host.deviceCall(name, *fields)) } | |
| 213 | + ?: return Result.failure( | |
| 214 | + Exception("${host.name} didn't answer. Its Vectrum Code may be too old for devices, or the connection dropped."), | |
| 215 | + ) | |
| 216 | + val error = reply.error | |
| 217 | + return if (error != null) Result.failure(Exception(error)) else Result.success(reply.ok ?: JsonNull) | |
| 218 | + } | |
| 219 | + | |
| 220 | + suspend fun list() = call("list") | |
| 221 | + | |
| 222 | + suspend fun retry() = call("retry") | |
| 223 | + | |
| 224 | + suspend fun configure(enabled: Boolean? = null, agentAccess: Boolean? = null, onboarding: Boolean? = null) = call( | |
| 225 | + "configure", | |
| 226 | + *listOfNotNull( | |
| 227 | + enabled?.let { "enabled" to JsonPrimitive(it) }, | |
| 228 | + agentAccess?.let { "agent_access" to JsonPrimitive(it) }, | |
| 229 | + onboarding?.let { "onboarding" to JsonPrimitive(it) }, | |
| 230 | + ).toTypedArray(), | |
| 231 | + ) | |
| 232 | + | |
| 233 | + suspend fun open(thread: String, device: String, boot: Boolean): Result<DeviceSession> = | |
| 234 | + call("open", "thread" to JsonPrimitive(thread), "device" to JsonPrimitive(device), "boot" to JsonPrimitive(boot)) | |
| 235 | + .mapCatching { DeviceJson.decodeFromJsonElement<DeviceSession>(it) } | |
| 236 | + | |
| 237 | + suspend fun close(thread: String, device: String?, shutdown: Boolean) = call( | |
| 238 | + "close", | |
| 239 | + *listOfNotNull( | |
| 240 | + "thread" to JsonPrimitive(thread), | |
| 241 | + device?.let { "device" to JsonPrimitive(it) }, | |
| 242 | + "shutdown" to JsonPrimitive(shutdown), | |
| 243 | + ).toTypedArray(), | |
| 244 | + ) | |
| 245 | + | |
| 246 | + suspend fun detail(device: String): Result<DeviceDetail> = | |
| 247 | + call("detail", "device" to JsonPrimitive(device)).mapCatching { DeviceJson.decodeFromJsonElement<DeviceDetail>(it) } | |
| 248 | + | |
| 249 | + suspend fun action(device: String, action: JsonObject): Result<DeviceDetail> = | |
| 250 | + call("action", "device" to JsonPrimitive(device), "action" to action) | |
| 251 | + .mapCatching { DeviceJson.decodeFromJsonElement<DeviceDetail>(it) } | |
| 252 | + | |
| 253 | + /** A PNG of the screen. */ | |
| 254 | + suspend fun screenshot(device: String): Result<ByteArray> = | |
| 255 | + call("screenshot", "device" to JsonPrimitive(device)).mapCatching { | |
| 256 | + android.util.Base64.decode((it as JsonPrimitive).content, android.util.Base64.DEFAULT) | |
| 257 | + } | |
| 258 | + | |
| 259 | + private companion object { | |
| 260 | + const val CALL_TIMEOUT = 240_000L | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** Percent-encodes a device id for a query or path, as the desktop's `urlencode`. */ | |
| 265 | + fun urlencode(text: String): String = buildString { | |
| 266 | + for (byte in text.toByteArray()) { | |
| 267 | + val b = byte.toInt() and 0xff | |
| 268 | + val c = b.toChar() | |
| 269 | + if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c == '-' || c == '_' || c == '.' || c == '~') append(c) | |
| 270 | + else append("%%%02X".format(b)) | |
| 271 | + } | |
| 272 | + } | |
| @@ -0,0 +1,232 @@ | ||
| 1 | + package cc.vectrum.code.ui.device | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.clickable | |
| 4 | + import androidx.compose.foundation.layout.Arrangement | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.Row | |
| 7 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 8 | + import androidx.compose.foundation.layout.height | |
| 9 | + import androidx.compose.foundation.layout.padding | |
| 10 | + import androidx.compose.foundation.rememberScrollState | |
| 11 | + import androidx.compose.foundation.shape.CircleShape | |
| 12 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 13 | + import androidx.compose.foundation.horizontalScroll | |
| 14 | + import androidx.compose.runtime.Composable | |
| 15 | + import androidx.compose.runtime.getValue | |
| 16 | + import androidx.compose.runtime.mutableIntStateOf | |
| 17 | + import androidx.compose.runtime.mutableStateOf | |
| 18 | + import androidx.compose.runtime.remember | |
| 19 | + import androidx.compose.runtime.setValue | |
| 20 | + import androidx.compose.ui.Alignment | |
| 21 | + import androidx.compose.ui.Modifier | |
| 22 | + import androidx.compose.ui.text.font.FontWeight | |
| 23 | + import androidx.compose.ui.unit.dp | |
| 24 | + import androidx.compose.ui.unit.sp | |
| 25 | + import cc.vectrum.code.data.HostState | |
| 26 | + import cc.vectrum.code.theme.Cap | |
| 27 | + import cc.vectrum.code.theme.CapKind | |
| 28 | + import cc.vectrum.code.theme.CapSize | |
| 29 | + import cc.vectrum.code.theme.Keycap | |
| 30 | + import cc.vectrum.code.theme.Led | |
| 31 | + import cc.vectrum.code.theme.StatusLed | |
| 32 | + import cc.vectrum.code.theme.Toggle | |
| 33 | + import cc.vectrum.code.theme.Txt | |
| 34 | + import cc.vectrum.code.theme.metal | |
| 35 | + import cc.vectrum.code.theme.palette | |
| 36 | + import cc.vectrum.code.theme.well | |
| 37 | + import cc.vectrum.code.ui.Lucide | |
| 38 | + import cc.vectrum.code.ui.LucideIcon | |
| 39 | + import cc.vectrum.code.ui.MetalDialog | |
| 40 | + import kotlinx.coroutines.launch | |
| 41 | + | |
| 42 | + const val HUB_DESCRIPTION = "Enable this machine to open simulators and emulators, and stream them into the Device panel." | |
| 43 | + const val AGENT_DESCRIPTION = "Allow new agent sessions to start and control devices, with required tools set up automatically." | |
| 44 | + | |
| 45 | + private val STEPS = listOf("Device hub", "Simulators", "Agent access") | |
| 46 | + | |
| 47 | + private enum class Pending { Hub, Check, Agent, Complete } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * "Set up devices on <host>", the Device panel's first-use dialog | |
| 51 | + * (src/ui/device/setup.rs): three steps (the device hub, simulator | |
| 52 | + * support, agent access), each saying what will run on the host before | |
| 53 | + * anything does. [onDismiss] says whether setup is done. | |
| 54 | + */ | |
| 55 | + @Composable | |
| 56 | + fun DeviceSetup(host: HostState, calls: DeviceCalls, state: DeviceHubState, onDismiss: (Boolean) -> Unit) { | |
| 57 | + val p = palette | |
| 58 | + var step by remember { mutableIntStateOf(0) } | |
| 59 | + var pending by remember { mutableStateOf<Pending?>(null) } | |
| 60 | + val enabled = !state.disabled | |
| 61 | + val busy = state.busy | |
| 62 | + val blocked = busy || pending != null | |
| 63 | + val localUnavailable = state.hosts.none { host -> host.platforms.any { it.available } } | |
| 64 | + val done = state.onboardingCompleted && !state.disabled | |
| 65 | + | |
| 66 | + fun configure(kind: Pending, enabled: Boolean? = null, agent: Boolean? = null, onboarding: Boolean? = null) { | |
| 67 | + pending = kind | |
| 68 | + DeviceRegistry.scope.launch { | |
| 69 | + val result = calls.configure(enabled, agent, onboarding) | |
| 70 | + pending = null | |
| 71 | + if (kind == Pending.Complete && result.isSuccess) onDismiss(true) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + MetalDialog(onDismiss = { onDismiss(done) }) { | |
| 76 | + Txt("Set up devices on ${host.name}", size = 16.sp, weight = FontWeight.SemiBold) | |
| 77 | + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { | |
| 78 | + Text("Review what runs on this machine before using simulators and emulators.") | |
| 79 | + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 80 | + STEPS.forEachIndexed { ix, label -> | |
| 81 | + val current = ix == step | |
| 82 | + val reachable = ix <= step && !blocked | |
| 83 | + Row( | |
| 84 | + Modifier | |
| 85 | + .height(26.dp) | |
| 86 | + .then(if (current) Modifier.metal(CircleShape, p.cap.brush, p.btn) else Modifier) | |
| 87 | + .then(if (reachable && !current) Modifier.clickable { step = ix } else Modifier) | |
| 88 | + .padding(horizontal = 10.dp), | |
| 89 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 90 | + verticalAlignment = Alignment.CenterVertically, | |
| 91 | + ) { | |
| 92 | + Keycap((ix + 1).toString()) | |
| 93 | + Txt(label, color = if (current) p.ink else p.ink3, size = 12.sp, maxLines = 1) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + if (step == 0) { | |
| 98 | + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 99 | + Heading("Enable the device hub") | |
| 100 | + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { | |
| 101 | + Text(HUB_DESCRIPTION, Modifier.weight(1f)) | |
| 102 | + Toggle(enabled) { on -> | |
| 103 | + if (pending == null && !busy) configure(Pending.Hub, enabled = on, agent = if (on) null else false) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + HubStatus(state, pending == Pending.Hub || (busy && pending != Pending.Agent)) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + if (step == 1 || (step == 0 && enabled && localUnavailable)) { | |
| 110 | + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 111 | + Heading("Check simulator support") | |
| 112 | + PlatformLine(state, "ios") | |
| 113 | + PlatformLine(state, "android") | |
| 114 | + Note("You can use either platform. Fixing a missing platform does not block the other one.") | |
| 115 | + Cap( | |
| 116 | + label = if (pending == Pending.Check) "Checking…" else "Check again", | |
| 117 | + size = CapSize.Compact, | |
| 118 | + enabled = enabled && !blocked, | |
| 119 | + ) { | |
| 120 | + pending = Pending.Check | |
| 121 | + DeviceRegistry.scope.launch { | |
| 122 | + calls.list() | |
| 123 | + pending = null | |
| 124 | + } | |
| 125 | + } | |
| 126 | + } | |
| 127 | + } | |
| 128 | + if (step == 2) { | |
| 129 | + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 130 | + Heading("Allow agent control") | |
| 131 | + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { | |
| 132 | + Text(AGENT_DESCRIPTION, Modifier.weight(1f)) | |
| 133 | + Toggle(state.agentAccess) { on -> | |
| 134 | + if (pending == null && !busy && !state.disabled) configure(Pending.Agent, agent = on) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + AgentStatus(state, pending == Pending.Agent) | |
| 138 | + Note("Leave this off to keep manual device controls without giving agents access.") | |
| 139 | + } | |
| 140 | + } | |
| 141 | + val detail = state.hostDetail | |
| 142 | + if (state.failed && detail != null) Txt(detail, color = p.red, size = 11.5.sp, lineHeight = 16.sp) | |
| 143 | + } | |
| 144 | + Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { | |
| 145 | + if (step == 0) { | |
| 146 | + Cap(label = "Cancel", kind = CapKind.Ghost, size = CapSize.Compact) { onDismiss(done) } | |
| 147 | + } else { | |
| 148 | + Cap(label = "Back", kind = CapKind.Ghost, size = CapSize.Compact, enabled = !blocked) { step = (step - 1).coerceAtLeast(0) } | |
| 149 | + } | |
| 150 | + if (step < 2) { | |
| 151 | + Cap(label = "Continue", kind = CapKind.Primary, size = CapSize.Compact, enabled = state.ready && pending == null) { step++ } | |
| 152 | + } else { | |
| 153 | + Cap( | |
| 154 | + label = if (pending == Pending.Complete) "Saving…" else "Done", | |
| 155 | + kind = CapKind.Primary, | |
| 156 | + size = CapSize.Compact, | |
| 157 | + enabled = state.ready && pending == null, | |
| 158 | + ) { configure(Pending.Complete, onboarding = true) } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + @Composable | |
| 165 | + private fun Heading(text: String) = Txt(text, size = 13.5.sp, weight = FontWeight.Medium) | |
| 166 | + | |
| 167 | + @Composable | |
| 168 | + private fun Text(text: String, modifier: Modifier = Modifier) = | |
| 169 | + Txt(text, modifier, color = palette.ink3, size = 12.5.sp, lineHeight = 18.sp) | |
| 170 | + | |
| 171 | + @Composable | |
| 172 | + private fun Note(text: String) = Txt(text, color = palette.ink3, size = 11.5.sp, lineHeight = 16.sp) | |
| 173 | + | |
| 174 | + /** "Installing device hub…" while it installs or starts, "Device hub is ready." once it runs. */ | |
| 175 | + @Composable | |
| 176 | + private fun HubStatus(state: DeviceHubState, pending: Boolean) { | |
| 177 | + if (!pending && !state.ready) return | |
| 178 | + val label = when { | |
| 179 | + !pending -> "Device hub is ready." | |
| 180 | + state.hostStatus == "installing" -> "Installing device hub…" | |
| 181 | + state.hostStatus == "starting" -> "Starting device hub…" | |
| 182 | + else -> "Updating device hub…" | |
| 183 | + } | |
| 184 | + StatusLine(label, pending) | |
| 185 | + } | |
| 186 | + | |
| 187 | + @Composable | |
| 188 | + private fun AgentStatus(state: DeviceHubState, pending: Boolean) { | |
| 189 | + if (pending) { | |
| 190 | + StatusLine( | |
| 191 | + when (state.hostStatus) { | |
| 192 | + "installing" -> "Installing agent tools…" | |
| 193 | + "starting" -> "Starting agent tools…" | |
| 194 | + else -> "Updating agent access…" | |
| 195 | + }, | |
| 196 | + true, | |
| 197 | + ) | |
| 198 | + } else if (state.agentAccess && state.ready && state.hosts.any { it.agentDeviceInstalled }) { | |
| 199 | + StatusLine("Agent tools are ready.", false) | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + @Composable | |
| 204 | + private fun StatusLine(label: String, pending: Boolean) { | |
| 205 | + val p = palette | |
| 206 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 207 | + if (pending) StatusLed(Led.Waiting, pulse = true) else LucideIcon(Lucide.Check, size = 12.dp, tint = p.greenInk) | |
| 208 | + Txt(label, color = p.ink3, size = 11.5.sp) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** One platform's line: ready, or what's missing. */ | |
| 213 | + @Composable | |
| 214 | + private fun PlatformLine(state: DeviceHubState, platform: String) { | |
| 215 | + val p = palette | |
| 216 | + val (ready, message) = state.platformStatus(platform) | |
| 217 | + Row( | |
| 218 | + Modifier.fillMaxWidth().well(p, RoundedCornerShape(10.dp)).padding(horizontal = 12.dp, vertical = 9.dp), | |
| 219 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 220 | + ) { | |
| 221 | + LucideIcon( | |
| 222 | + if (ready) Lucide.CircleCheck else Lucide.CircleAlert, | |
| 223 | + Modifier.padding(top = 1.dp), | |
| 224 | + size = 15.dp, | |
| 225 | + tint = if (ready) p.greenInk else p.ink3, | |
| 226 | + ) | |
| 227 | + Column { | |
| 228 | + Txt(platformLabel(platform), size = 13.sp, weight = FontWeight.Medium) | |
| 229 | + Txt(message, color = p.ink3, size = 11.5.sp, lineHeight = 16.sp) | |
| 230 | + } | |
| 231 | + } | |
| 232 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.