Vectrum
Sign In

Android: terminals with a VT emulator, and the device panel with live screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent f54d80ecommit36ef715

Showing 19 changed files with 5201 additions and 4 deletions.

+94−0android/app/src/main/java/cc/vectrum/code/term/Keys.ktaddedView File
@@ -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+ }
+105−0android/app/src/main/java/cc/vectrum/code/term/Line.ktaddedView File
@@ -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+ }
+975−0android/app/src/main/java/cc/vectrum/code/term/Terminal.ktaddedView File
@@ -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+ }
+181−0android/app/src/main/java/cc/vectrum/code/term/Text.ktaddedView File
@@ -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+ }
+1−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -47,6 +47,7 @@ enum class Lucide(val res: Int) {
4747 CircleDot(R.drawable.lucide_circle_dot),
4848 CircleStop(R.drawable.lucide_circle_stop),
4949 CircleX(R.drawable.lucide_circle_x),
50+ ClipboardPaste(R.drawable.lucide_clipboard_paste),
5051 Clock(R.drawable.lucide_clock),
5152 Code(R.drawable.lucide_code),
5253 Contrast(R.drawable.lucide_contrast),
+441−2android/app/src/main/java/cc/vectrum/code/ui/device/DevicePanel.ktView File
@@ -1,10 +1,449 @@
11 package cc.vectrum.code.ui.device
22
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
321 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
437 import cc.vectrum.code.data.AppState
538 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
653 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
757
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+ */
964 @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+ }
+549−0android/app/src/main/java/cc/vectrum/code/ui/device/DeviceView.ktaddedView File
@@ -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+ }
+120−0android/app/src/main/java/cc/vectrum/code/ui/device/Devices.ktaddedView File
@@ -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+ }
+272−0android/app/src/main/java/cc/vectrum/code/ui/device/Model.ktaddedView File
@@ -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+ }
+232−0android/app/src/main/java/cc/vectrum/code/ui/device/Setup.ktaddedView File
@@ -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+ }
+813−0android/app/src/main/java/cc/vectrum/code/ui/device/Stream.ktaddedView File

Large diffs are not rendered by default.

+131−0android/app/src/main/java/cc/vectrum/code/ui/terminal/KeyInput.ktaddedView File

Large diffs are not rendered by default.

+382−2android/app/src/main/java/cc/vectrum/code/ui/terminal/TerminalPanel.ktView File

Large diffs are not rendered by default.

+265−0android/app/src/main/java/cc/vectrum/code/ui/terminal/TerminalScreen.ktaddedView File

Large diffs are not rendered by default.

+281−0android/app/src/main/java/cc/vectrum/code/ui/terminal/Terminals.ktaddedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_clipboard_paste.xmladdedView File

Large diffs are not rendered by default.

+266−0android/app/src/test/java/cc/vectrum/code/term/TerminalTest.ktaddedView File

Large diffs are not rendered by default.

+82−0android/app/src/test/java/cc/vectrum/code/ui/device/DeviceModelTest.ktaddedView File

Large diffs are not rendered by default.

+1−0android/scripts/icons.txtView File

Large diffs are not rendered by default.