# Conflicts: # src/remote/mod.rs # src/ui/settings/connections.rs # src/ui/workspace/remote.rs
Showing 286 changed files with 23856 additions and 13 deletions.
| @@ -5249,6 +5249,12 @@ dependencies = [ | ||
| 5249 | 5249 | "bytemuck", |
| 5250 | 5250 | ] |
| 5251 | 5251 | |
| 5252 | + [[package]] | |
| 5253 | + name = "qrcode" | |
| 5254 | + version = "0.14.1" | |
| 5255 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5256 | + checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" | |
| 5257 | + | |
| 5252 | 5258 | [[package]] |
| 5253 | 5259 | name = "quick-error" |
| 5254 | 5260 | version = "2.0.1" |
| @@ -7482,6 +7488,7 @@ dependencies = [ | ||
| 7482 | 7488 | "libc", |
| 7483 | 7489 | "mermaid-rs-renderer", |
| 7484 | 7490 | "openh264", |
| 7491 | + "qrcode", | |
| 7485 | 7492 | "serde", |
| 7486 | 7493 | "serde_json", |
| 7487 | 7494 | "smol", |
| @@ -19,6 +19,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } | ||
| 19 | 19 | alacritty_terminal = "0.26" |
| 20 | 20 | openh264 = "0.9" |
| 21 | 21 | mermaid-rs-renderer = { version = "0.3.1", default-features = false } |
| 22 | + qrcode = { version = "0.14.1", default-features = false } | |
| 22 | 23 | |
| 23 | 24 | [profile.dev] |
| 24 | 25 | debug = "line-tables-only" |
| @@ -203,12 +203,17 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 203 | 203 | side-by-side layout. |
| 204 | 204 | - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on |
| 205 | 205 | one device, and paste its pairing code (address and key) under Settings → Connections on |
| 206 | - another. The host's projects and threads appear in the sidebar tagged with its name; open, | |
| 206 | + another, or scan the QR code shown next to it with a phone. The host's projects and threads appear in the sidebar tagged with its name; open, | |
| 207 | 207 | continue or start threads there and they run on the host. Their terminal drawer (Ctrl+J) |
| 208 | 208 | runs its shells on the host too, in the thread's folder there. The first time a device connects |
| 209 | 209 | with settings that differ from yours, a dialog asks whether to give it your settings or |
| 210 | 210 | leave its own as they are. The connection is a WebSocket guarded by the key but not |
| 211 | 211 | encrypted, so use it on trusted networks or over a VPN. |
| 212 | + - An Android companion app ([`android/`](android/README.md)), in Kotlin and Jetpack Compose: | |
| 213 | + a remote client that looks like the desktop, in the same themes. Pair it by scanning the QR | |
| 214 | + code under Settings → Connections; the sidebar, threads, composer and its pickers, | |
| 215 | + approvals and questions, diffs, settings, terminals and the Device panel work from the | |
| 216 | + phone, on the desktop's agents and files. | |
| 212 | 217 | - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's: a |
| 213 | 218 | search field over the pages (press `/` to search, ↑/↓ and Enter to jump to a setting, |
| 214 | 219 | which scrolls into view and pulses), a "Settings / Page" breadcrumb, and every setting as |
| @@ -355,6 +360,15 @@ version not tagged yet, and a read & write API token in `VECTRUM_TOKEN` or | ||
| 355 | 360 | `VECTRUM_URL` (`https://vectrum.cc`) and `VECTRUM_REPO` (from the origin remote) pick where |
| 356 | 361 | it goes. |
| 357 | 362 | |
| 363 | + ### Android | |
| 364 | + | |
| 365 | + `cd android && ./gradlew :app:assembleDebug` builds the companion app into | |
| 366 | + `android/app/build/outputs/apk/debug/` (`assembleRelease` for a shrunk build, signed with the | |
| 367 | + debug key until there's an upload key). It needs JDK 21 and the Android SDK (platform 37) in | |
| 368 | + `ANDROID_HOME` or `android/local.properties`. The themes come from the desktop's palettes: | |
| 369 | + after changing them, `cargo test export_android_themes -- --ignored` rewrites | |
| 370 | + `android/app/src/main/assets/themes.json`. | |
| 371 | + | |
| 358 | 372 | ## Layout |
| 359 | 373 | |
| 360 | 374 | - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi` or `jcode api --stdio` |
| @@ -0,0 +1,6 @@ | ||
| 1 | + .gradle/ | |
| 2 | + .kotlin/ | |
| 3 | + build/ | |
| 4 | + local.properties | |
| 5 | + *.iml | |
| 6 | + .idea/ | |
| @@ -0,0 +1,61 @@ | ||
| 1 | + # Vectrum Code for Android | |
| 2 | + | |
| 3 | + A companion app for [Vectrum Code](../README.md): the desktop's projects and threads on a phone, | |
| 4 | + in the desktop's look and themes. It's a remote client like another desktop: threads run on | |
| 5 | + the computer, with its agents, files, terminals and devices. | |
| 6 | + | |
| 7 | + ## Pairing | |
| 8 | + | |
| 9 | + On the computer, open Settings → Connections and turn on **Allow remote connections**. Then, on | |
| 10 | + the phone, scan the QR code shown under **Pairing code**, or paste the code | |
| 11 | + (`192.168.2.10:47823/<key>`). A `vectrum://…` link pairs too. Several computers can be paired; | |
| 12 | + their threads share the sidebar, tagged with each one's name. The connection isn't encrypted, | |
| 13 | + so pair on networks you trust or over a VPN such as Tailscale or WireGuard. | |
| 14 | + | |
| 15 | + ## What it has | |
| 16 | + | |
| 17 | + - The sidebar's inbox: pinned and active threads, the Working, Snoozed and Settled shelves, | |
| 18 | + search, the project filter, and the thread menu on a long press (pin, settle, snooze, wake, | |
| 19 | + rename, copy, delete) with the desktop's undo notices. | |
| 20 | + - The transcript: Markdown, tool calls folded into **Worked for** rows, subagents, plans, | |
| 21 | + questions, the changed-files card and the diff view. | |
| 22 | + - The composer: model, reasoning and access pickers from the computer's agents, attachments, | |
| 23 | + the `/` menu, steering and queueing, and the dock for approvals, questions, plans, queued | |
| 24 | + messages, background tasks, usage limits and the task list. | |
| 25 | + - New threads on any paired computer's projects, in the current checkout or a new worktree, | |
| 26 | + and adding projects (a folder there, a new one, or a clone). | |
| 27 | + - Settings: the computer's environment settings (with project overrides), Appearance with every | |
| 28 | + theme family, Providers, Source Control, Connections, Archive and About. | |
| 29 | + - The right panel: subagents, terminals (shells on the computer, in the thread's folder, with | |
| 30 | + an extra-keys row) and the Device panel (the computer's emulators and simulators, streamed | |
| 31 | + and controlled from the phone). | |
| 32 | + | |
| 33 | + Left out because the remote connection doesn't carry them: the browser panel, usage reports, | |
| 34 | + `@` file mentions, editing providers and starring models. | |
| 35 | + | |
| 36 | + ## Build | |
| 37 | + | |
| 38 | + ```sh | |
| 39 | + ./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk | |
| 40 | + ./gradlew :app:testDebugUnitTest | |
| 41 | + ``` | |
| 42 | + | |
| 43 | + JDK 21 and the Android SDK (platform 37) are needed; point `local.properties` at the SDK | |
| 44 | + (`sdk.dir=…`) or set `ANDROID_HOME`. | |
| 45 | + | |
| 46 | + ## Layout | |
| 47 | + | |
| 48 | + - `net/`: the desktop's remote protocol (`Protocol.kt`, mirroring `src/remote/mod.rs`) and the | |
| 49 | + WebSocket that keeps a host connected (`Connection.kt`). | |
| 50 | + - `data/`: the app's state as Compose state: paired hosts, their projects, threads and open | |
| 51 | + transcripts (`AppState.kt`), and this phone's own preferences (`DevicePrefs.kt`). | |
| 52 | + - `theme/`: MetalUI. `Palette.kt` reads `assets/themes.json`, which the desktop exports | |
| 53 | + (`cargo test export_android_themes -- --ignored`), so every theme matches it exactly; | |
| 54 | + `Metal.kt` has the surfaces (raised, well, plate, with the desktop's layered shadows), caps, | |
| 55 | + switches, LEDs, keycaps and engraved labels. | |
| 56 | + - `ui/`: `sidebar/`, `chat/` (header, transcript, Markdown), `composer/` (composer, pickers, | |
| 57 | + dock, start screen), `diff/`, `settings/`, `pair/` (pairing, adding projects), `panels/` | |
| 58 | + (right panel, subagents, command palette), `terminal/` and `device/`. | |
| 59 | + - `term/`: the terminal emulator the terminal panel draws. | |
| 60 | + - `scripts/lucide.py`: turns the desktop's Lucide icons into vector drawables (`icons.txt` | |
| 61 | + lists the ones in use). | |
| @@ -0,0 +1,44 @@ | ||
| 1 | + plugins { | |
| 2 | + alias(libs.plugins.android.application) | |
| 3 | + alias(libs.plugins.kotlin.compose) | |
| 4 | + alias(libs.plugins.kotlin.serialization) | |
| 5 | + } | |
| 6 | + | |
| 7 | + android { | |
| 8 | + namespace = "cc.vectrum.code" | |
| 9 | + compileSdk = 37 | |
| 10 | + | |
| 11 | + defaultConfig { | |
| 12 | + applicationId = "cc.vectrum.code" | |
| 13 | + minSdk = 29 | |
| 14 | + targetSdk = 37 | |
| 15 | + versionCode = 1 | |
| 16 | + versionName = "0.1.0" | |
| 17 | + } | |
| 18 | + | |
| 19 | + buildFeatures { compose = true } | |
| 20 | + | |
| 21 | + testOptions { unitTests.isReturnDefaultValues = true } | |
| 22 | + | |
| 23 | + buildTypes { | |
| 24 | + release { | |
| 25 | + isMinifyEnabled = true | |
| 26 | + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") | |
| 27 | + // Signed with the debug key until there's an upload key. | |
| 28 | + signingConfig = signingConfigs.getByName("debug") | |
| 29 | + } | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + dependencies { | |
| 34 | + implementation(libs.androidx.activity.compose) | |
| 35 | + implementation(libs.compose.runtime) | |
| 36 | + implementation(libs.compose.foundation) | |
| 37 | + implementation(libs.compose.ui) | |
| 38 | + implementation(libs.compose.ui.text) | |
| 39 | + implementation(libs.coroutines.android) | |
| 40 | + implementation(libs.serialization.json) | |
| 41 | + implementation(libs.okhttp) | |
| 42 | + implementation(libs.code.scanner) | |
| 43 | + testImplementation(libs.junit) | |
| 44 | + } | |
| @@ -0,0 +1,5 @@ | ||
| 1 | + # kotlinx.serialization keeps its generated serializers through its own rules. | |
| 2 | + -dontwarn okhttp3.internal.platform.** | |
| 3 | + -dontwarn org.conscrypt.** | |
| 4 | + -dontwarn org.bouncycastle.** | |
| 5 | + -dontwarn org.openjsse.** | |
| @@ -0,0 +1,39 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| 3 | + | |
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | |
| 5 | + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | |
| 6 | + | |
| 7 | + <application | |
| 8 | + android:name=".VectrumApp" | |
| 9 | + android:icon="@mipmap/ic_launcher" | |
| 10 | + android:roundIcon="@mipmap/ic_launcher" | |
| 11 | + android:label="Vectrum Code" | |
| 12 | + android:theme="@style/Theme.Vectrum" | |
| 13 | + android:usesCleartextTraffic="true" | |
| 14 | + android:supportsRtl="true"> | |
| 15 | + | |
| 16 | + <!-- Fetches Google's QR code scanner with the app, for pairing. --> | |
| 17 | + <meta-data | |
| 18 | + android:name="com.google.mlkit.vision.DEPENDENCIES" | |
| 19 | + android:value="barcode_ui" /> | |
| 20 | + | |
| 21 | + <activity | |
| 22 | + android:name=".MainActivity" | |
| 23 | + android:exported="true" | |
| 24 | + android:windowSoftInputMode="adjustResize" | |
| 25 | + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|density|uiMode"> | |
| 26 | + <intent-filter> | |
| 27 | + <action android:name="android.intent.action.MAIN" /> | |
| 28 | + <category android:name="android.intent.category.LAUNCHER" /> | |
| 29 | + </intent-filter> | |
| 30 | + <!-- A pairing code as a link: vectrum://192.168.2.10:47823/<key> --> | |
| 31 | + <intent-filter> | |
| 32 | + <action android:name="android.intent.action.VIEW" /> | |
| 33 | + <category android:name="android.intent.category.DEFAULT" /> | |
| 34 | + <category android:name="android.intent.category.BROWSABLE" /> | |
| 35 | + <data android:scheme="vectrum" /> | |
| 36 | + </intent-filter> | |
| 37 | + </activity> | |
| 38 | + </application> | |
| 39 | + </manifest> | |
| @@ -0,0 +1 @@ | ||
| 1 | + {"themes":[{"id":"graphite","name":"Graphite","palette":{"dark":true,"page":"#19191bff","s_hi":"#2c2c2fff","s":"#252527ff","s_lo":"#202022ff","well_top":"#141415ff","well_bot":"#18181aff","ink":"#f2f2f0ff","ink2":"#a6a6a9ff","ink3":"#939397ff","icon":"#d6d6d8ff","engrave":"#ffffff7d","tag_ring":"#ffffff29","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#222225fa","menu_row_hover":"#ffffff0f","field_ink":"#8e8e93ff","primary_ink":"#1b1b1dff","green_deep":"#3fb97aff","green_ink":"#5fcb93ff","red":"#ff7a66ff","amber":"#f5bf55ff","amber_ink":"#e7c98aff","blue_ink":"#8fb0ffff","cap":["#313134ff","#28282bff"],"pressed":["#18181aff","#1c1c1eff"],"row_on":["#333337ff","#2a2a2dff"],"kbd":["#35353aff","#2a2a2dff"],"primary":["#f4f3f0ff","#e4e3dfff"],"primary_pressed":["#dad9d5ff","#e3e2deff"],"danger":["#e6594eff","#d0433aff"],"danger_pressed":["#be352cff","#cf4238ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#8b8b8eff","#4a4a4dff"],"tints":["#fb794aff","#ed6ba2ff","#25c2c3ff","#ce76dcff","#3e9bfaff","#8986faff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffb3"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000047"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#78140a4d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#6e0a0024"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#aa1e143d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#50000073"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6e0a008c"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"bone","name":"Bone","palette":{"dark":false,"page":"#f3f3f1ff","s_hi":"#fcfbf9ff","s":"#f6f5f2ff","s_lo":"#f0efebff","well_top":"#e6e5e1ff","well_bot":"#edece8ff","ink":"#1b1b1dff","ink2":"#5c5c60ff","ink3":"#6c6c6fff","icon":"#3a3a3dff","engrave":"#282620a6","tag_ring":"#28262033","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fbfaf8fa","menu_row_hover":"#0000000d","field_ink":"#8e8e93ff","primary_ink":"#ffffffff","green_deep":"#3fb97aff","green_ink":"#2b7a52ff","red":"#d8453bff","amber":"#f5bf55ff","amber_ink":"#9a6a12ff","blue_ink":"#3558c9ff","cap":["#ffffffff","#f4f3f0ff"],"pressed":["#e9e8e4ff","#efeeeaff"],"row_on":["#ffffffff","#f6f5f2ff"],"kbd":["#ffffffff","#f2f1eeff"],"primary":["#38383cff","#252528ff"],"primary_pressed":["#1c1c1eff","#242427ff"],"danger":["#e6594eff","#d0433aff"],"danger_pressed":["#be352cff","#cf4238ff"],"region":["#28262007","#28262003"],"led_off":["#d6d5d1ff","#c9c8c4ff"],"tints":["#d0560eff","#d54487ff","#108a8bff","#b653c5ff","#0f7ddcff","#736aecff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#6e645012"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1816100b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1816100d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#18161014"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#18161021"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#18161021"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#6e64500f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1816100a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1816100a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#18161014"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#1816101a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1816100d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#18161012"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#18161017"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3c372824"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3c37281c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#1816101a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#3c372812"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":5.0,"spread":1.0,"inset":true,"color":"#ffffff12"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff2e"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000080"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000026"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000038"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#000000b3"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#78140a4d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#6e0a0024"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#aa1e143d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#50000073"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6e0a008c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"tokyo-night","name":"Tokyo Night","palette":{"dark":true,"page":"#16161eff","s_hi":"#20212fff","s":"#1a1b26ff","s_lo":"#171721ff","well_top":"#0e0f15ff","well_bot":"#111219ff","ink":"#c0caf5ff","ink2":"#9ba3cdff","ink3":"#767da4ff","icon":"#b1bbe5ff","engrave":"#c0caf580","tag_ring":"#c0caf529","rule":"#00000073","rule_lip":"#ffffff0b","row_hover":"#ffffff0f","menu_bg":"#181923fa","menu_row_hover":"#ffffff0f","field_ink":"#64698bff","primary_ink":"#16161eff","green_deep":"#9ece6aff","green_ink":"#9ece6aff","red":"#f7768eff","amber":"#e0af68ff","amber_ink":"#e0af68ff","blue_ink":"#7aa2f7ff","cap":["#242635ff","#1d1e2aff"],"pressed":["#111219ff","#14151dff"],"row_on":["#262838ff","#1e202cff"],"kbd":["#292a3bff","#1e202cff"],"primary":["#8daff8ff","#6795f6ff"],"primary_pressed":["#4a80f4ff","#5d8ef5ff"],"danger":["#f55976ff","#f33356ff"],"danger_pressed":["#f1163fff","#f33054ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#757b98ff","#3b3e4fff"],"tints":["#ff9e64ff","#f7768eff","#73dacaff","#bb9af7ff","#7aa2f7ff","#9d7cd8ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"tokyo-night-storm","name":"Tokyo Night Storm","palette":{"dark":true,"page":"#1f2335ff","s_hi":"#2a2e44ff","s":"#24283bff","s_lo":"#1f2333ff","well_top":"#131620ff","well_bot":"#181a27ff","ink":"#c0caf5ff","ink2":"#a1a9d1ff","ink3":"#8289acff","icon":"#b4bde6ff","engrave":"#c0caf580","tag_ring":"#c0caf529","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#212536fa","menu_row_hover":"#ffffff0f","field_ink":"#6f7596ff","primary_ink":"#1f2335ff","green_deep":"#9ece6aff","green_ink":"#9ece6aff","red":"#f7768eff","amber":"#e0af68ff","amber_ink":"#e0af68ff","blue_ink":"#7aa2f7ff","cap":["#2d334bff","#272b3fff"],"pressed":["#181a27ff","#1c1f2dff"],"row_on":["#2f354eff","#282d42ff"],"kbd":["#323751ff","#282d42ff"],"primary":["#8daff8ff","#6795f6ff"],"primary_pressed":["#4a80f4ff","#5d8ef5ff"],"danger":["#f55976ff","#f33356ff"],"danger_pressed":["#f1163fff","#f33054ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#7a81a1ff","#434860ff"],"tints":["#ff9e64ff","#f7768eff","#73dacaff","#bb9af7ff","#7aa2f7ff","#9d7cd8ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"catppuccin-mocha","name":"Catppuccin Mocha","palette":{"dark":true,"page":"#181825ff","s_hi":"#242437ff","s":"#1e1e2eff","s_lo":"#1a1a28ff","well_top":"#101019ff","well_bot":"#14141eff","ink":"#cdd6f4ff","ink2":"#a6adc8ff","ink3":"#7f849cff","icon":"#bdc6e2ff","engrave":"#cdd6f480","tag_ring":"#cdd6f429","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#1c1c2afa","menu_row_hover":"#ffffff0f","field_ink":"#6c7086ff","primary_ink":"#181825ff","green_deep":"#a6e3a1ff","green_ink":"#a6e3a1ff","red":"#f38ba8ff","amber":"#f9e2afff","amber_ink":"#f9e2afff","blue_ink":"#89b4faff","cap":["#28283dff","#212132ff"],"pressed":["#14141eff","#171723ff"],"row_on":["#2a2a40ff","#222234ff"],"kbd":["#2c2c44ff","#222234ff"],"primary":["#d6b9f9ff","#c093f5ff"],"primary_pressed":["#b077f3ff","#bb8af4ff"],"danger":["#ef6189ff","#eb3c6dff"],"danger_pressed":["#e82158ff","#eb3a6bff"],"region":["#ffffff05","#ffffff03"],"led_off":["#7e839bff","#414356ff"],"tints":["#fab387ff","#f5c2e7ff","#94e2d5ff","#cba6f7ff","#89b4faff","#b4befeff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"catppuccin-macchiato","name":"Catppuccin Macchiato","palette":{"dark":true,"page":"#1e2030ff","s_hi":"#2a2d43ff","s":"#24273aff","s_lo":"#1f2232ff","well_top":"#13151fff","well_bot":"#181a26ff","ink":"#cad3f5ff","ink2":"#a6aecdff","ink3":"#838aa4ff","icon":"#bcc4e5ff","engrave":"#cad3f580","tag_ring":"#cad3f529","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#212435fa","menu_row_hover":"#ffffff0f","field_ink":"#70768fff","primary_ink":"#1e2030ff","green_deep":"#a6da95ff","green_ink":"#a6da95ff","red":"#ed8796ff","amber":"#eed49fff","amber_ink":"#eed49fff","blue_ink":"#8aadf4ff","cap":["#2e3149ff","#272a3eff"],"pressed":["#181a26ff","#1c1e2dff"],"row_on":["#30334dff","#282b41ff"],"kbd":["#323650ff","#282b41ff"],"primary":["#d1b3f8ff","#bb8df4ff"],"primary_pressed":["#aa71f2ff","#b584f3ff"],"danger":["#e8687bff","#e3445cff"],"danger_pressed":["#df2a45ff","#e3425aff"],"region":["#ffffff05","#ffffff03"],"led_off":["#7f86a1ff","#45495fff"],"tints":["#f5a97fff","#f5bde6ff","#8bd5caff","#c6a0f6ff","#8aadf4ff","#b7bdf8ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"catppuccin-frappe","name":"Catppuccin Frappé","palette":{"dark":true,"page":"#292c3cff","s_hi":"#363b4fff","s":"#303446ff","s_lo":"#2a2d3dff","well_top":"#1a1c26ff","well_bot":"#20222eff","ink":"#c6d0f5ff","ink2":"#acb4d3ff","ink3":"#9299b2ff","icon":"#bcc5e8ff","engrave":"#c6d0f580","tag_ring":"#c6d0f529","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#2c3041fa","menu_row_hover":"#ffffff0f","field_ink":"#7e859cff","primary_ink":"#292c3cff","green_deep":"#a6d189ff","green_ink":"#a6d189ff","red":"#e78284ff","amber":"#e5c890ff","amber_ink":"#e5c890ff","blue_ink":"#8caaeeff","cap":["#3a3f55ff","#33374aff"],"pressed":["#20222eff","#252836ff"],"row_on":["#3c4158ff","#34394cff"],"kbd":["#3f445bff","#34394cff"],"primary":["#d3aeeaff","#c18ee2ff"],"primary_pressed":["#b475dcff","#bd86e0ff"],"danger":["#e36d6fff","#dc4a4dff"],"danger_pressed":["#d73134ff","#dc484bff"],"region":["#ffffff05","#ffffff03"],"led_off":["#828aa6ff","#4e5369ff"],"tints":["#ef9f76ff","#f4b8e4ff","#81c8beff","#ca9ee6ff","#8caaeeff","#babbf1ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"github-dark","name":"GitHub Dark","palette":{"dark":true,"page":"#010409ff","s_hi":"#121820ff","s":"#0d1117ff","s_lo":"#0b0f14ff","well_top":"#07090cff","well_bot":"#090b0fff","ink":"#e6edf3ff","ink2":"#b2b9c2ff","ink3":"#7d8590ff","icon":"#d1d8dfff","engrave":"#e6edf380","tag_ring":"#e6edf329","rule":"#00000073","rule_lip":"#ffffff12","row_hover":"#ffffff0f","menu_bg":"#0c1015fa","menu_row_hover":"#ffffff0f","field_ink":"#676e78ff","primary_ink":"#ffffffff","green_deep":"#3fb950ff","green_ink":"#3fb950ff","red":"#f85149ff","amber":"#d29922ff","amber_ink":"#d29922ff","blue_ink":"#4493f8ff","cap":["#161d27ff","#10141cff"],"pressed":["#090b0fff","#0a0d12ff"],"row_on":["#181f2aff","#11161eff"],"kbd":["#1a222eff","#11161eff"],"primary":["#27963dff","#1f762fff"],"primary_pressed":["#185e26ff","#1d6e2cff"],"danger":["#f85a53ff","#f7352cff"],"danger_pressed":["#f6190eff","#f73229ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#848a90ff","#383d43ff"],"tints":["#ffa657ff","#db61a2ff","#a5d6ffff","#d2a8ffff","#79c0ffff","#a371f7ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"github-dark-dimmed","name":"GitHub Dark Dimmed","palette":{"dark":true,"page":"#1c2128ff","s_hi":"#282e37ff","s":"#22272eff","s_lo":"#1e2228ff","well_top":"#121519ff","well_bot":"#161a1eff","ink":"#adbac7ff","ink2":"#96a2afff","ink3":"#7e8b97ff","icon":"#a4b1bdff","engrave":"#adbac780","tag_ring":"#adbac729","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#1f242afa","menu_row_hover":"#ffffff0f","field_ink":"#6c7782ff","primary_ink":"#ffffffff","green_deep":"#57ab5aff","green_ink":"#57ab5aff","red":"#e76058ff","amber":"#c69026ff","amber_ink":"#c69026ff","blue_ink":"#539bf5ff","cap":["#2d333cff","#252a32ff"],"pressed":["#161a1eff","#1a1e23ff"],"row_on":["#2f363fff","#272c34ff"],"kbd":["#313843ff","#272c34ff"],"primary":["#3a8b40ff","#2e6f32ff"],"primary_pressed":["#255929ff","#2b672fff"],"danger":["#e8645dff","#e24239ff"],"danger_pressed":["#dd2a20ff","#e24037ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#6e7882ff","#3e444dff"],"tints":["#f69d50ff","#c96198ff","#96d0ffff","#dcbdfbff","#6cb6ffff","#986ee2ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"one-dark","name":"One Dark","palette":{"dark":true,"page":"#21252bff","s_hi":"#2e333cff","s":"#282c34ff","s_lo":"#23262dff","well_top":"#16181cff","well_bot":"#1a1d22ff","ink":"#abb2bfff","ink2":"#9fa4afff","ink3":"#8d919aff","icon":"#a6acb9ff","engrave":"#abb2bf80","tag_ring":"#abb2bf29","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#252930fa","menu_row_hover":"#ffffff0f","field_ink":"#787d86ff","primary_ink":"#21252bff","green_deep":"#98c379ff","green_ink":"#98c379ff","red":"#e17079ff","amber":"#e5c07bff","amber_ink":"#e5c07bff","blue_ink":"#61afefff","cap":["#333842ff","#2b2f38ff"],"pressed":["#1a1d22ff","#1f2228ff"],"row_on":["#353a45ff","#2d313aff"],"kbd":["#383d48ff","#2d313aff"],"primary":["#74b8f1ff","#4ea6edff"],"primary_pressed":["#3398eaff","#45a1ecff"],"danger":["#e17079ff","#da4f59ff"],"danger_pressed":["#d43542ff","#d94c57ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#707680ff","#424750ff"],"tints":["#d19a66ff","#e06c75ff","#56b6c2ff","#c678ddff","#61afefff","#e5c07bff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"nord","name":"Nord","palette":{"dark":true,"page":"#2e3440ff","s_hi":"#41495bff","s":"#3b4252ff","s_lo":"#333947ff","well_top":"#20242cff","well_bot":"#272c36ff","ink":"#eceff4ff","ink2":"#c8ced9ff","ink3":"#a5adbfff","icon":"#dee2e9ff","engrave":"#eceff480","tag_ring":"#eceff429","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#363d4cfa","menu_row_hover":"#ffffff0f","field_ink":"#9098a9ff","primary_ink":"#2e3440ff","green_deep":"#a3be8cff","green_ink":"#a6c08fff","red":"#d89fa4ff","amber":"#ebcb8bff","amber_ink":"#ebcb8bff","blue_ink":"#a3bad2ff","cap":["#454e61ff","#3e4556ff"],"pressed":["#272c36ff","#2d333fff"],"row_on":["#485063ff","#3f4758ff"],"kbd":["#4a5367ff","#3f4758ff"],"primary":["#97c8d6ff","#79b8caff"],"primary_pressed":["#63adc2ff","#72b4c7ff"],"danger":["#c57078ff","#b9525cff"],"danger_pressed":["#a9444eff","#b8515bff"],"region":["#ffffff05","#ffffff03"],"led_off":["#9ca1abff","#5e6572ff"],"tints":["#d08770ff","#cc8289ff","#8fbcbbff","#b48eadff","#81a1c1ff","#7c99bcff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"gruvbox-dark","name":"Gruvbox Dark","palette":{"dark":true,"page":"#1d2021ff","s_hi":"#2f2f2fff","s":"#282828ff","s_lo":"#232323ff","well_top":"#161616ff","well_bot":"#1a1a1aff","ink":"#ebdbb2ff","ink2":"#caba9bff","ink3":"#a89984ff","icon":"#decea9ff","engrave":"#ebdbb280","tag_ring":"#ebdbb229","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#252525fa","menu_row_hover":"#ffffff0f","field_ink":"#8e8272ff","primary_ink":"#1d2021ff","green_deep":"#b8bb26ff","green_ink":"#b8bb26ff","red":"#fb523eff","amber":"#fabd2fff","amber_ink":"#fabd2fff","blue_ink":"#83a598ff","cap":["#343434ff","#2c2c2cff"],"pressed":["#1a1a1aff","#1f1f1fff"],"row_on":["#373737ff","#2d2d2dff"],"kbd":["#3a3a3aff","#2d2d2dff"],"primary":["#fe8b2dff","#fe7505ff"],"primary_pressed":["#e36701ff","#f77001ff"],"danger":["#fb5643ff","#fb331bff"],"danger_pressed":["#f21e05ff","#fa3018ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#938a74ff","#4f4c44ff"],"tints":["#fe8019ff","#fb4934ff","#8ec07cff","#d3869bff","#83a598ff","#fabd2fff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"rose-pine","name":"Rosé Pine","palette":{"dark":true,"page":"#191724ff","s_hi":"#252337ff","s":"#1f1d2eff","s_lo":"#1b1928ff","well_top":"#111019ff","well_bot":"#14131eff","ink":"#e0def4ff","ink2":"#b8b5cfff","ink3":"#908caaff","icon":"#d0cee5ff","engrave":"#e0def480","tag_ring":"#e0def429","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#1d1b2afa","menu_row_hover":"#ffffff0f","field_ink":"#797691ff","primary_ink":"#191724ff","green_deep":"#9ccfd8ff","green_ink":"#9ccfd8ff","red":"#eb6f92ff","amber":"#f6c177ff","amber_ink":"#f6c177ff","blue_ink":"#c4a7e7ff","cap":["#29273dff","#222032ff"],"pressed":["#14131eff","#181623ff"],"row_on":["#2b2940ff","#232135ff"],"kbd":["#2e2b44ff","#232135ff"],"primary":["#f0cbcaff","#e6adaaff"],"primary_pressed":["#e09692ff","#e4a5a2ff"],"danger":["#ea668bff","#e54270ff"],"danger_pressed":["#e1275cff","#e4406eff"],"region":["#ffffff05","#ffffff03"],"led_off":["#89879bff","#464456ff"],"tints":["#ebbcbaff","#eb6f92ff","#9ccfd8ff","#c4a7e7ff","#327793ff","#f6c177ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"rose-pine-moon","name":"Rosé Pine Moon","palette":{"dark":true,"page":"#232136ff","s_hi":"#302d48ff","s":"#2a273fff","s_lo":"#252237ff","well_top":"#171522ff","well_bot":"#1c1a2aff","ink":"#e0def4ff","ink2":"#b8b5cfff","ink3":"#908caaff","icon":"#d0cee5ff","engrave":"#e0def480","tag_ring":"#e0def429","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#27243afa","menu_row_hover":"#ffffff0f","field_ink":"#7c7895ff","primary_ink":"#232136ff","green_deep":"#9ccfd8ff","green_ink":"#9ccfd8ff","red":"#eb6f92ff","amber":"#f6c177ff","amber_ink":"#f6c177ff","blue_ink":"#c4a7e7ff","cap":["#34314eff","#2d2a43ff"],"pressed":["#1c1a2aff","#201e31ff"],"row_on":["#363352ff","#2e2b46ff"],"kbd":["#393555ff","#2e2b46ff"],"primary":["#edaaa8ff","#e78a86ff"],"primary_pressed":["#e1716dff","#e5817eff"],"danger":["#ea668bff","#e54270ff"],"danger_pressed":["#e1275cff","#e4406eff"],"region":["#ffffff05","#ffffff03"],"led_off":["#8e8ca3ff","#4e4c63ff"],"tints":["#ea9a97ff","#eb6f92ff","#9ccfd8ff","#c4a7e7ff","#3e8fb0ff","#f6c177ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"solarized-dark","name":"Solarized Dark","palette":{"dark":true,"page":"#00212bff","s_hi":"#003745ff","s":"#002b36ff","s_lo":"#00252fff","well_top":"#00171dff","well_bot":"#001c24ff","ink":"#9eaaaaff","ink2":"#9aaaaeff","ink3":"#7f979fff","icon":"#9caaacff","engrave":"#9eaaaa80","tag_ring":"#9eaaaa29","rule":"#00000073","rule_lip":"#ffffff0d","row_hover":"#ffffff0f","menu_bg":"#002832fa","menu_row_hover":"#ffffff0f","field_ink":"#66828aff","primary_ink":"#00212bff","green_deep":"#859900ff","green_ink":"#8ea300ff","red":"#e66a68ff","amber":"#b58900ff","amber_ink":"#c49500ff","blue_ink":"#48a0deff","cap":["#003f4fff","#00313dff"],"pressed":["#001c24ff","#00212aff"],"row_on":["#004354ff","#003441ff"],"kbd":["#00475aff","#003441ff"],"primary":["#3295daff","#2380c1ff"],"primary_pressed":["#1e6ea7ff","#217ab8ff"],"danger":["#df4340ff","#d32724ff"],"danger_pressed":["#b9221fff","#d12623ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#577176ff","#20444dff"],"tints":["#d44e17ff","#d6438aff","#2aa198ff","#7075c5ff","#268bd2ff","#b58900ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"dracula","name":"Dracula","palette":{"dark":true,"page":"#21222cff","s_hi":"#2e313eff","s":"#282a36ff","s_lo":"#23252fff","well_top":"#16171dff","well_bot":"#1a1c24ff","ink":"#f8f8f2ff","ink2":"#bcc2d3ff","ink3":"#7f8cb5ff","icon":"#e0e2e6ff","engrave":"#f8f8f280","tag_ring":"#f8f8f229","rule":"#00000073","rule_lip":"#ffffff09","row_hover":"#ffffff0f","menu_bg":"#252732fa","menu_row_hover":"#ffffff0f","field_ink":"#6e789bff","primary_ink":"#21222cff","green_deep":"#50fa7bff","green_ink":"#50fa7bff","red":"#ff5555ff","amber":"#f1fa8cff","amber_ink":"#f1fa8cff","blue_ink":"#8be9fdff","cap":["#333544ff","#2b2d3aff"],"pressed":["#1a1c24ff","#1f202aff"],"row_on":["#353747ff","#2d2f3cff"],"kbd":["#373a4bff","#2d2f3cff"],"primary":["#c9a6faff","#b180f8ff"],"primary_pressed":["#9f63f6ff","#ab76f7ff"],"danger":["#ff5050ff","#ff2727ff"],"danger_pressed":["#ff0909ff","#ff2525ff"],"region":["#ffffff05","#ffffff03"],"led_off":["#9a9b9dff","#52535cff"],"tints":["#ffb86cff","#ff79c6ff","#8be9fdff","#bd93f9ff","#50fa7bff","#f1fa8cff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0e"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000073"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000038"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#00000057"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0000004d"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffff0d"},{"x":1.5,"y":2.5,"blur":3.0,"spread":-1.0,"inset":true,"color":"#ffffff17"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#0000004d"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000066"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#00000033"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#00000047"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0f"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000008c"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0000004d"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffff0d"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff1a"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000004d"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#0000004d"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#000000a6"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000080"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#00000099"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0000008c"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffff12"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#00000080"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000059"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffff0d"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#00000059"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#00000073"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#00000059"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000099"}],"led_off_sh":[{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#00000040"}]}},{"id":"tokyo-night-day","name":"Tokyo Night Day","palette":{"dark":false,"page":"#d0d5e3ff","s_hi":"#e8e8ecff","s":"#e1e2e7ff","s_lo":"#dadbe1ff","well_top":"#ced0d8ff","well_bot":"#d6d7deff","ink":"#3054a7ff","ink2":"#3c5599ff","ink3":"#5466a8ff","icon":"#3554a2ff","engrave":"#3054a7a6","tag_ring":"#3054a733","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#e5e6eafa","menu_row_hover":"#0000000d","field_ink":"#707fb5ff","primary_ink":"#ffffffff","green_deep":"#587539ff","green_ink":"#506b34ff","red":"#d80a46ff","amber":"#8c6c3eff","amber_ink":"#7a5e36ff","blue_ink":"#155fc5ff","cap":["#f2f2f5ff","#dfe0e5ff"],"pressed":["#d1d2daff","#d9dae0ff"],"row_on":["#f2f2f5ff","#e1e2e7ff"],"kbd":["#f2f2f5ff","#dddee4ff"],"primary":["#4088ebff","#1c72e7ff"],"primary_pressed":["#1664ceff","#186de1ff"],"danger":["#f63d73ff","#f41757ff"],"danger_pressed":["#e10b49ff","#f41455ff"],"region":["#20212807","#20212803"],"led_off":["#bcbecaff","#aeb1beff"],"tints":["#b15c00ff","#f52a65ff","#118c74ff","#9854f1ff","#2e7de9ff","#7847bdff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#54586a12"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1213160b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1213160d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#12131614"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#12131621"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#12131621"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#54586a0f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1213160a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1213160a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#12131614"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#1213161a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1213160d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#12131612"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#12131617"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#2c2e3824"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#2c2e381c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#1213161a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#2c2e3812"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0c38734d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0b316624"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#1253ab3d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#08244a73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0b31668c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#7a06274d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#6b052324"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#b4083a3d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#4e041973"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6b05238c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"catppuccin-latte","name":"Catppuccin Latte","palette":{"dark":false,"page":"#e6e9efff","s_hi":"#f6f7faff","s":"#eff1f5ff","s_lo":"#e7eaf0ff","well_top":"#dadfe8ff","well_bot":"#e3e6edff","ink":"#4c4f69ff","ink2":"#5d6078ff","ink3":"#6e7287ff","icon":"#53566fff","engrave":"#4c4f69a6","tag_ring":"#4c4f6933","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#f4f5f8fa","menu_row_hover":"#0000000d","field_ink":"#888b9dff","primary_ink":"#ffffffff","green_deep":"#40a02bff","green_ink":"#327c21ff","red":"#d20f39ff","amber":"#df8e1dff","amber_ink":"#976014ff","blue_ink":"#145ff5ff","cap":["#ffffffff","#eceff3ff"],"pressed":["#dde1eaff","#e6e9efff"],"row_on":["#ffffffff","#eff1f5ff"],"kbd":["#ffffffff","#eaedf2ff"],"primary":["#934cf1ff","#7d26edff"],"primary_pressed":["#6d12e3ff","#771dedff"],"danger":["#e5103eff","#bf0e34ff"],"danger_pressed":["#a20c2cff","#bd0d33ff"],"region":["#1b212c07","#1b212c03"],"led_off":["#c6cddcff","#b7c0d2ff"],"tints":["#ef5801ff","#e44ebcff","#179299ff","#8839efff","#1e66f5ff","#687efdff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#49587512"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0f12190b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0f12190d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#0f121914"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#0f121921"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#0f121921"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#4958750f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0f12190a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0f12190a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#0f121914"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#0f12191a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0f12190d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0f121912"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0f121917"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#262e3e24"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#262e3e1c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#0f12191a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#262e3e12"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#390a764d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#32086824"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#540eaf3d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#24064b73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#3208688c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#7708204d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#69071c24"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#b00d303d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#4c051573"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#69071c8c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"github-light","name":"GitHub Light","palette":{"dark":false,"page":"#f6f8faff","s_hi":"#ffffffff","s":"#ffffffff","s_lo":"#f8f8f8ff","well_top":"#eeeeeeff","well_bot":"#f5f5f5ff","ink":"#1f2328ff","ink2":"#42484fff","ink3":"#656d76ff","icon":"#2d3238ff","engrave":"#1f2328a6","tag_ring":"#1f232833","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fffffffa","menu_row_hover":"#0000000d","field_ink":"#848a91ff","primary_ink":"#ffffffff","green_deep":"#1a7f37ff","green_ink":"#1a7f37ff","red":"#d1242fff","amber":"#9a6700ff","amber_ink":"#9a6700ff","blue_ink":"#0969daff","cap":["#ffffffff","#fdfdfdff"],"pressed":["#f0f0f0ff","#f8f8f8ff"],"row_on":["#ffffffff","#ffffffff"],"kbd":["#ffffffff","#fbfbfbff"],"primary":["#239944ff","#1b7736ff"],"primary_pressed":["#165e2aff","#196f32ff"],"danger":["#db2e39ff","#c0212bff"],"danger_pressed":["#a51d25ff","#bd212bff"],"region":["#24242407","#24242403"],"led_off":["#dededeff","#d1d1d1ff"],"tints":["#bc4c00ff","#bf3989ff","#1b7c83ff","#8250dfff","#0550aeff","#6639baff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#5f5f5f12"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1414140d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#14141414"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#14141421"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#14141421"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#5f5f5f0f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1414140a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#14141414"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#1414141a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#14141412"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#14141417"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#32323224"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3232321c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#1414141a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#32323212"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#18682f4d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#155b2924"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#239a453d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#0f421e73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#155b298c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6d13184d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#60101624"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#a11c243d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#460c1073"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6010168c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"one-light","name":"One Light","palette":{"dark":false,"page":"#eaeaebff","s_hi":"#ffffffff","s":"#fafafaff","s_lo":"#f3f3f3ff","well_top":"#e9e9e9ff","well_bot":"#f0f0f0ff","ink":"#383a42ff","ink2":"#585a62ff","ink3":"#787a82ff","icon":"#45474fff","engrave":"#383a42a6","tag_ring":"#383a4233","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fefefefa","menu_row_hover":"#0000000d","field_ink":"#92939aff","primary_ink":"#ffffffff","green_deep":"#50a14fff","green_ink":"#3f7f3eff","red":"#e14133ff","amber":"#c18401ff","amber_ink":"#986801ff","blue_ink":"#2d6bf1ff","cap":["#ffffffff","#f8f8f8ff"],"pressed":["#ebebebff","#f3f3f3ff"],"row_on":["#ffffffff","#fafafaff"],"kbd":["#ffffffff","#f6f6f6ff"],"primary":["#5386f3ff","#2d6af1ff"],"primary_pressed":["#1056efff","#2364f0ff"],"danger":["#e7665bff","#e14637ff"],"danger_pressed":["#da3020ff","#e14335ff"],"region":["#24242407","#24242403"],"led_off":["#d9d9d9ff","#ccccccff"],"tints":["#986801ff","#e45649ff","#0184bcff","#a626a4ff","#4078f2ff","#c18401ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#5f5f5f12"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1414140d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#14141414"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#14141421"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#14141421"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#5f5f5f0f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#1414140a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#14141414"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#1414141a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#1414140d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#14141412"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#14141417"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#32323224"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3232321c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#1414141a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#32323212"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#082b774d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#07266924"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#0c40b13d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#051c4c73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0726698c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6f18104d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#62150e24"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#a424183d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#47100b73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#62150e8c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"gruvbox-light","name":"Gruvbox Light","palette":{"dark":false,"page":"#f2e5bcff","s_hi":"#fcf4d2ff","s":"#fbf1c7ff","s_lo":"#faeebbff","well_top":"#f9e9a7ff","well_bot":"#faecb4ff","ink":"#3c3836ff","ink2":"#5c544dff","ink3":"#7c6f64ff","icon":"#49433fff","engrave":"#3c3836a6","tag_ring":"#3c383633","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fcf3cefa","menu_row_hover":"#0000000d","field_ink":"#958978ff","primary_ink":"#ffffffff","green_deep":"#79740eff","green_ink":"#74700dff","red":"#9d0006ff","amber":"#b57614ff","amber_ink":"#956110ff","blue_ink":"#076678ff","cap":["#fdf8e4ff","#fbf0c3ff"],"pressed":["#f9eaacff","#faeeb9ff"],"row_on":["#fdf8e4ff","#fbf1c7ff"],"kbd":["#fdf8e4ff","#faefc0ff"],"primary":["#c34103ff","#9b3303ff"],"primary_pressed":["#7d2902ff","#913002ff"],"danger":["#b10007ff","#890005ff"],"danger_pressed":["#6a0004ff","#860005ff"],"region":["#2d291b07","#2d291b03"],"led_off":["#f7e289ff","#f5dc71ff"],"tints":["#af3a03ff","#9d0006ff","#427b58ff","#8f3f71ff","#076678ff","#b57614ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#776e4712"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19170f0b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19170f0d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#19170f14"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#19170f21"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#19170f21"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#776e470f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19170f0a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19170f0a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#19170f14"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#19170f1a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19170f0d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19170f12"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#19170f17"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f3a2624"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f3a261c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#19170f1a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#3f3a2612"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#7d2a024d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#6e250224"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#ba3d033d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#501b0173"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6e25028c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#8000054d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#70000424"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#bd00073d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#52000373"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#7000048c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"rose-pine-dawn","name":"Rosé Pine Dawn","palette":{"dark":false,"page":"#faf4edff","s_hi":"#ffffffff","s":"#fffaf3ff","s_lo":"#fff4e6ff","well_top":"#ffecd1ff","well_bot":"#fff2dfff","ink":"#575279ff","ink2":"#686486ff","ink3":"#797593ff","icon":"#5e597eff","engrave":"#575279a6","tag_ring":"#57527933","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fffdfbfa","menu_row_hover":"#0000000d","field_ink":"#9490a6ff","primary_ink":"#ffffffff","green_deep":"#56949fff","green_ink":"#487b85ff","red":"#b4637aff","amber":"#ea9d34ff","amber_ink":"#a26511ff","blue_ink":"#80679dff","cap":["#ffffffff","#fff8efff"],"pressed":["#ffeed6ff","#fff4e4ff"],"row_on":["#ffffffff","#fffaf3ff"],"kbd":["#ffffffff","#fff7ebff"],"primary":["#dc918eff","#d2736eff"],"primary_pressed":["#cb5c57ff","#d06b67ff"],"danger":["#bb7186ff","#ad556eff"],"danger_pressed":["#9a4a61ff","#ad546dff"],"region":["#2d251b07","#2d251b03"],"led_off":["#ffdeb1ff","#ffd497ff"],"tints":["#d37772ff","#b4637aff","#56949fff","#907aa9ff","#286983ff","#cc7e15ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#77634712"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19150f0b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19150f0d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#19150f14"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#19150f21"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#19150f21"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#7763470f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19150f0a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19150f0a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#19150f14"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#19150f1a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19150f0d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19150f12"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#19150f17"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f342624"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f34261c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#19150f1a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#3f342612"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#61211e4d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#561d1b24"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#90312d3d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#3e151373"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#561d1b8c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#5629364d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#4c243024"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#7f3d503d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#371a2373"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#4c24308c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}},{"id":"solarized-light","name":"Solarized Light","palette":{"dark":false,"page":"#eee8d5ff","s_hi":"#fefaeeff","s":"#fdf6e3ff","s_lo":"#fcf2d7ff","well_top":"#fbecc3ff","well_bot":"#fcf0d0ff","ink":"#566b72ff","ink2":"#5a6b6eff","ink3":"#6c7c7cff","icon":"#576b71ff","engrave":"#566b72a6","tag_ring":"#566b7233","rule":"#00000013","rule_lip":"#ffffffcc","row_hover":"#ffffffb3","menu_bg":"#fef8eafa","menu_row_hover":"#0000000d","field_ink":"#899490ff","primary_ink":"#ffffffff","green_deep":"#859900ff","green_ink":"#667500ff","red":"#dc322fff","amber":"#b58900ff","amber_ink":"#8c6a00ff","blue_ink":"#2074afff","cap":["#ffffffff","#fdf5dfff"],"pressed":["#fbedc8ff","#fcf2d5ff"],"row_on":["#ffffffff","#fdf6e3ff"],"kbd":["#ffffffff","#fcf4dcff"],"primary":["#3295daff","#2380c1ff"],"primary_pressed":["#1e6ea7ff","#217ab8ff"],"danger":["#df4340ff","#d32724ff"],"danger_pressed":["#b9221fff","#d12623ff"],"region":["#2d281b07","#2d281b03"],"led_off":["#f9e2a5ff","#f7da8dff"],"tints":["#cb4b16ff","#d33682ff","#299d94ff","#6c71c4ff","#268bd2ff","#b08500ff"],"raise":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#776a4712"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19160f0b"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19160f0d"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-6.0,"inset":false,"color":"#19160f14"},{"x":0.0,"y":26.0,"blur":50.0,"spread":-16.0,"inset":false,"color":"#19160f21"},{"x":0.0,"y":52.0,"blur":96.0,"spread":-36.0,"inset":false,"color":"#19160f21"}],"raise_lite":[{"x":0.0,"y":0.0,"blur":6.0,"spread":2.0,"inset":true,"color":"#ffffffbf"},{"x":2.0,"y":3.0,"blur":3.0,"spread":-1.0,"inset":true,"color":"#fffffff2"},{"x":-1.0,"y":-3.0,"blur":5.0,"spread":-2.0,"inset":true,"color":"#776a470f"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19160f0a"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19160f0a"},{"x":0.0,"y":8.0,"blur":18.0,"spread":-8.0,"inset":false,"color":"#19160f14"},{"x":0.0,"y":22.0,"blur":40.0,"spread":-18.0,"inset":false,"color":"#19160f1a"}],"raise_sm":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffd9"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#19160f0d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#19160f12"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#19160f17"}],"btn":[{"x":0.0,"y":0.0,"blur":4.0,"spread":1.0,"inset":true,"color":"#ffffffe6"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffffff"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#0000000f"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#0000000f"},{"x":0.0,"y":4.0,"blur":9.0,"spread":-3.0,"inset":false,"color":"#00000014"}],"pressed_sh":[{"x":0.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f382624"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":3.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"well":[{"x":1.0,"y":2.0,"blur":5.0,"spread":-1.0,"inset":true,"color":"#3f38261c"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#19160f1a"},{"x":0.0,"y":0.5,"blur":0.0,"spread":0.0,"inset":false,"color":"#ffffffe6"}],"region_sh":[{"x":0.0,"y":2.0,"blur":6.0,"spread":-1.0,"inset":true,"color":"#3f382612"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":true,"color":"#00000009"},{"x":0.0,"y":-2.0,"blur":4.0,"spread":-2.0,"inset":true,"color":"#ffffffe6"}],"primary_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#14476c4d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#113f5f24"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#1d6aa03d"}],"primary_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#0d2e4573"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#113f5f8c"}],"danger_sh":[{"x":0.0,"y":0.0,"blur":6.0,"spread":1.0,"inset":true,"color":"#ffffff24"},{"x":1.0,"y":2.0,"blur":2.0,"spread":-1.0,"inset":true,"color":"#ffffff52"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6d14124d"},{"x":0.0,"y":1.0,"blur":2.0,"spread":0.0,"inset":false,"color":"#60121024"},{"x":0.0,"y":5.0,"blur":12.0,"spread":-4.0,"inset":false,"color":"#a21e1b3d"}],"danger_pressed_sh":[{"x":0.0,"y":1.0,"blur":3.0,"spread":0.0,"inset":true,"color":"#460d0c73"},{"x":0.0,"y":0.0,"blur":0.0,"spread":0.5,"inset":false,"color":"#6012108c"}],"led_off_sh":[{"x":0.0,"y":1.0,"blur":1.0,"spread":0.0,"inset":true,"color":"#00000033"}]}}],"families":[{"name":"Vectrum","themes":["bone","graphite"]},{"name":"Tokyo Night","themes":["tokyo-night-day","tokyo-night","tokyo-night-storm"]},{"name":"Catppuccin","themes":["catppuccin-latte","catppuccin-mocha","catppuccin-macchiato","catppuccin-frappe"]},{"name":"GitHub","themes":["github-light","github-dark","github-dark-dimmed"]},{"name":"One","themes":["one-light","one-dark"]},{"name":"Nord","themes":["nord"]},{"name":"Gruvbox","themes":["gruvbox-light","gruvbox-dark"]},{"name":"Rosé Pine","themes":["rose-pine-dawn","rose-pine","rose-pine-moon"]},{"name":"Solarized","themes":["solarized-light","solarized-dark"]},{"name":"Dracula","themes":["dracula"]}]} | |
| @@ -0,0 +1,68 @@ | ||
| 1 | + package cc.vectrum.code | |
| 2 | + | |
| 3 | + import android.content.Intent | |
| 4 | + import android.graphics.Color | |
| 5 | + import android.os.Bundle | |
| 6 | + import androidx.activity.ComponentActivity | |
| 7 | + import androidx.activity.SystemBarStyle | |
| 8 | + import androidx.activity.compose.setContent | |
| 9 | + import androidx.activity.enableEdgeToEdge | |
| 10 | + import androidx.compose.foundation.isSystemInDarkTheme | |
| 11 | + import androidx.compose.runtime.DisposableEffect | |
| 12 | + import androidx.compose.runtime.LaunchedEffect | |
| 13 | + import androidx.compose.runtime.remember | |
| 14 | + import cc.vectrum.code.net.parsePairing | |
| 15 | + import cc.vectrum.code.net.withPort | |
| 16 | + import cc.vectrum.code.theme.ProvidePalette | |
| 17 | + import cc.vectrum.code.theme.Themes | |
| 18 | + import cc.vectrum.code.ui.Root | |
| 19 | + import cc.vectrum.code.ui.Nav | |
| 20 | + | |
| 21 | + class MainActivity : ComponentActivity() { | |
| 22 | + private val app get() = (application as VectrumApp).state | |
| 23 | + private val nav = Nav() | |
| 24 | + | |
| 25 | + override fun onCreate(savedInstanceState: Bundle?) { | |
| 26 | + super.onCreate(savedInstanceState) | |
| 27 | + handle(intent) | |
| 28 | + setContent { | |
| 29 | + val systemDark = isSystemInDarkTheme() | |
| 30 | + val prefs = app.prefs | |
| 31 | + val palette = when (prefs.themeMode) { | |
| 32 | + "light" -> Themes.find(prefs.lightTheme, false) | |
| 33 | + "dark" -> Themes.find(prefs.darkTheme, true) | |
| 34 | + else -> if (systemDark) Themes.find(prefs.darkTheme, true) else Themes.find(prefs.lightTheme, false) | |
| 35 | + } | |
| 36 | + val dark = palette.dark | |
| 37 | + DisposableEffect(dark) { | |
| 38 | + val style = if (dark) SystemBarStyle.dark(Color.TRANSPARENT) | |
| 39 | + else SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) | |
| 40 | + enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style) | |
| 41 | + onDispose {} | |
| 42 | + } | |
| 43 | + ProvidePalette(palette) { | |
| 44 | + Root(app, nav) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + override fun onNewIntent(intent: Intent) { | |
| 50 | + super.onNewIntent(intent) | |
| 51 | + handle(intent) | |
| 52 | + } | |
| 53 | + | |
| 54 | + override fun onResume() { | |
| 55 | + super.onResume() | |
| 56 | + app.resume() | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** A `vectrum://address/key` link pairs with that host. */ | |
| 60 | + private fun handle(intent: Intent?) { | |
| 61 | + val data = intent?.data ?: return | |
| 62 | + if (data.scheme != "vectrum") return | |
| 63 | + val (address, key) = parsePairing(data.toString()) | |
| 64 | + if (address.isBlank() || key == null) return | |
| 65 | + app.addHost(withPort(address), key) | |
| 66 | + nav.home() | |
| 67 | + } | |
| 68 | + } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | + package cc.vectrum.code | |
| 2 | + | |
| 3 | + import android.app.Application | |
| 4 | + import cc.vectrum.code.data.AppState | |
| 5 | + import cc.vectrum.code.theme.Themes | |
| 6 | + | |
| 7 | + class VectrumApp : Application() { | |
| 8 | + lateinit var state: AppState | |
| 9 | + private set | |
| 10 | + | |
| 11 | + override fun onCreate() { | |
| 12 | + super.onCreate() | |
| 13 | + Themes.load(this) | |
| 14 | + state = AppState(this) | |
| 15 | + state.start() | |
| 16 | + } | |
| 17 | + } | |
| @@ -0,0 +1,411 @@ | ||
| 1 | + package cc.vectrum.code.data | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import android.content.SharedPreferences | |
| 5 | + import android.os.Build | |
| 6 | + import androidx.compose.runtime.getValue | |
| 7 | + import androidx.compose.runtime.mutableStateListOf | |
| 8 | + import androidx.compose.runtime.mutableStateMapOf | |
| 9 | + import androidx.compose.runtime.mutableStateOf | |
| 10 | + import androidx.compose.runtime.setValue | |
| 11 | + import cc.vectrum.code.net.ClientMessage | |
| 12 | + import cc.vectrum.code.net.Connection | |
| 13 | + import cc.vectrum.code.net.ConnectionStatus | |
| 14 | + import cc.vectrum.code.net.Folder | |
| 15 | + import cc.vectrum.code.net.ProjectInfo | |
| 16 | + import cc.vectrum.code.net.RemoteFileDiff | |
| 17 | + import cc.vectrum.code.net.RemoteInstance | |
| 18 | + import cc.vectrum.code.net.RemoteModel | |
| 19 | + import cc.vectrum.code.net.SavedItem | |
| 20 | + import cc.vectrum.code.net.ServerMessage | |
| 21 | + import cc.vectrum.code.net.SettingsPatch | |
| 22 | + import cc.vectrum.code.net.ThreadAction | |
| 23 | + import cc.vectrum.code.net.ThreadDetail | |
| 24 | + import cc.vectrum.code.net.ThreadSummary | |
| 25 | + import cc.vectrum.code.net.WireJson | |
| 26 | + import kotlinx.coroutines.CompletableDeferred | |
| 27 | + import kotlinx.serialization.Serializable | |
| 28 | + import kotlinx.serialization.builtins.ListSerializer | |
| 29 | + import kotlinx.serialization.json.JsonElement | |
| 30 | + import kotlinx.serialization.json.JsonObject | |
| 31 | + import kotlinx.serialization.json.JsonPrimitive | |
| 32 | + import java.util.UUID | |
| 33 | + | |
| 34 | + /** A desktop this phone pairs with, as saved: its address and key. */ | |
| 35 | + @Serializable | |
| 36 | + data class SavedHost( | |
| 37 | + val id: String = UUID.randomUUID().toString(), | |
| 38 | + /** The host's name, as it last said; shown until it says again. */ | |
| 39 | + val name: String = "", | |
| 40 | + val address: String = "", | |
| 41 | + val key: String = "", | |
| 42 | + val enabled: Boolean = true, | |
| 43 | + ) | |
| 44 | + | |
| 45 | + /** A thread anywhere: the host it lives on and its id there. */ | |
| 46 | + data class ThreadRef(val host: String, val thread: String) | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * Everything the app knows, as Compose state: the paired hosts, each one's | |
| 50 | + * projects and threads, and the threads that are open. Messages from hosts | |
| 51 | + * are applied on the main thread, so the UI reads it all directly. | |
| 52 | + */ | |
| 53 | + class AppState(context: Context) { | |
| 54 | + private val store: SharedPreferences = context.getSharedPreferences("vectrum", Context.MODE_PRIVATE) | |
| 55 | + val prefs = DevicePrefs(store) | |
| 56 | + | |
| 57 | + val hosts = mutableStateListOf<HostState>() | |
| 58 | + | |
| 59 | + /** A short message at the bottom of the screen, like the desktop's notices. */ | |
| 60 | + var notice by mutableStateOf<Notice?>(null) | |
| 61 | + private set | |
| 62 | + | |
| 63 | + private val deviceName = listOfNotNull(Build.MANUFACTURER?.replaceFirstChar { it.uppercase() }, Build.MODEL) | |
| 64 | + .distinct() | |
| 65 | + .joinToString(" ") | |
| 66 | + .ifBlank { "Android" } | |
| 67 | + | |
| 68 | + init { | |
| 69 | + val saved = store.getString("hosts", null)?.let { | |
| 70 | + runCatching { WireJson.decodeFromString(ListSerializer(SavedHost.serializer()), it) }.getOrNull() | |
| 71 | + } ?: emptyList() | |
| 72 | + saved.forEach { hosts.add(HostState(it, this)) } | |
| 73 | + } | |
| 74 | + | |
| 75 | + fun start() = hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) } | |
| 76 | + | |
| 77 | + fun stop() = hosts.forEach { it.disconnect() } | |
| 78 | + | |
| 79 | + /** Reconnects whatever dropped, as when the app comes back to the foreground. */ | |
| 80 | + fun resume() = hosts.filter { it.saved.enabled && it.status !is ConnectionStatus.Connected }.forEach { it.retry() } | |
| 81 | + | |
| 82 | + fun host(id: String): HostState? = hosts.find { it.saved.id == id } | |
| 83 | + | |
| 84 | + fun addHost(address: String, key: String, name: String = ""): HostState { | |
| 85 | + val existing = hosts.find { it.saved.address == address } | |
| 86 | + if (existing != null) { | |
| 87 | + existing.update(existing.saved.copy(key = key, enabled = true)) | |
| 88 | + existing.retry() | |
| 89 | + return existing | |
| 90 | + } | |
| 91 | + val host = HostState(SavedHost(name = name, address = address, key = key), this) | |
| 92 | + hosts.add(host) | |
| 93 | + persistHosts() | |
| 94 | + host.connect(deviceName) | |
| 95 | + return host | |
| 96 | + } | |
| 97 | + | |
| 98 | + fun removeHost(host: HostState) { | |
| 99 | + host.disconnect() | |
| 100 | + hosts.remove(host) | |
| 101 | + persistHosts() | |
| 102 | + } | |
| 103 | + | |
| 104 | + fun setHostEnabled(host: HostState, enabled: Boolean) { | |
| 105 | + host.update(host.saved.copy(enabled = enabled)) | |
| 106 | + if (enabled) host.connect(deviceName) else host.disconnect() | |
| 107 | + } | |
| 108 | + | |
| 109 | + internal fun persistHosts() { | |
| 110 | + val list = hosts.map { it.saved } | |
| 111 | + store.edit().putString("hosts", WireJson.encodeToString(ListSerializer(SavedHost.serializer()), list)).apply() | |
| 112 | + } | |
| 113 | + | |
| 114 | + fun thread(ref: ThreadRef): ThreadSummary? = host(ref.host)?.threads?.get(ref.thread) | |
| 115 | + | |
| 116 | + fun showNotice(text: String, error: Boolean = false, undo: (() -> Unit)? = null) { | |
| 117 | + notice = Notice(text, error, undo, System.currentTimeMillis()) | |
| 118 | + } | |
| 119 | + | |
| 120 | + fun clearNotice(shown: Notice) { | |
| 121 | + if (notice == shown) notice = null | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + data class Notice(val text: String, val error: Boolean, val undo: (() -> Unit)?, val at: Long) | |
| 126 | + | |
| 127 | + /** One paired host: its connection and what it shares. */ | |
| 128 | + class HostState(saved: SavedHost, private val app: AppState) { | |
| 129 | + var saved by mutableStateOf(saved) | |
| 130 | + private set | |
| 131 | + var status by mutableStateOf<ConnectionStatus>(ConnectionStatus.Offline("Not connected")) | |
| 132 | + private set | |
| 133 | + | |
| 134 | + /** The name the host gives itself, or the address until it does. */ | |
| 135 | + val name: String get() = saved.name.ifBlank { saved.address } | |
| 136 | + var version by mutableStateOf("") | |
| 137 | + private set | |
| 138 | + | |
| 139 | + val projects = mutableStateListOf<ProjectInfo>() | |
| 140 | + val threads = mutableStateMapOf<String, ThreadSummary>() | |
| 141 | + | |
| 142 | + /** The host's environment settings and `project_overrides`, raw. */ | |
| 143 | + var settings by mutableStateOf(JsonObject(emptyMap())) | |
| 144 | + private set | |
| 145 | + var instances by mutableStateOf<List<RemoteInstance>>(emptyList()) | |
| 146 | + private set | |
| 147 | + var favorites by mutableStateOf<List<String>>(emptyList()) | |
| 148 | + private set | |
| 149 | + | |
| 150 | + /** Threads open in the app, with their transcripts. */ | |
| 151 | + val open = mutableStateMapOf<String, OpenThread>() | |
| 152 | + | |
| 153 | + /** The host's devices, as `DeviceState` last said, for the Device panel. */ | |
| 154 | + var deviceState by mutableStateOf<JsonObject?>(null) | |
| 155 | + private set | |
| 156 | + | |
| 157 | + private var connection: Connection? = null | |
| 158 | + private var nextRequest = 1L | |
| 159 | + private val subscribed = mutableSetOf<String>() | |
| 160 | + private val createdCallbacks = mutableMapOf<Long, (String) -> Unit>() | |
| 161 | + private val folderRequests = mutableMapOf<String, CompletableDeferred<ServerMessage.Folders>>() | |
| 162 | + private val diffRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DiffReply>>() | |
| 163 | + private val deviceRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DeviceReply>>() | |
| 164 | + private val terminals = mutableMapOf<Long, TerminalListener>() | |
| 165 | + | |
| 166 | + /** Listeners for device events agents cause: (thread, device or `null` for all, opened). */ | |
| 167 | + val deviceEvents = mutableListOf<(String, JsonObject?, String?) -> Unit>() | |
| 168 | + | |
| 169 | + val connected: Boolean get() = status is ConnectionStatus.Connected | |
| 170 | + | |
| 171 | + internal fun update(saved: SavedHost) { | |
| 172 | + this.saved = saved | |
| 173 | + app.persistHosts() | |
| 174 | + } | |
| 175 | + | |
| 176 | + fun connect(deviceName: String) { | |
| 177 | + connection?.stop() | |
| 178 | + val connection = Connection(saved.address, saved.key, deviceName, ::onStatus, ::onMessage) | |
| 179 | + this.connection = connection | |
| 180 | + connection.start() | |
| 181 | + } | |
| 182 | + | |
| 183 | + fun disconnect() { | |
| 184 | + connection?.stop() | |
| 185 | + connection = null | |
| 186 | + status = ConnectionStatus.Offline("Turned off") | |
| 187 | + } | |
| 188 | + | |
| 189 | + fun retry() { | |
| 190 | + val connection = connection | |
| 191 | + if (connection == null) { | |
| 192 | + if (saved.enabled) connect(Build.MODEL ?: "Android") | |
| 193 | + } else { | |
| 194 | + connection.reconnect() | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false | |
| 199 | + | |
| 200 | + private fun onStatus(status: ConnectionStatus) { | |
| 201 | + this.status = status | |
| 202 | + if (status is ConnectionStatus.Connected) { | |
| 203 | + version = status.version | |
| 204 | + if (saved.name != status.host) update(saved.copy(name = status.host)) | |
| 205 | + send(ClientMessage.Catalog) | |
| 206 | + subscribed.forEach { send(ClientMessage.Subscribe(it)) } | |
| 207 | + } | |
| 208 | + if (status !is ConnectionStatus.Connected && status !is ConnectionStatus.Connecting) { | |
| 209 | + // Whatever waits on a reply won't get one from this connection. | |
| 210 | + folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) } | |
| 211 | + folderRequests.clear() | |
| 212 | + diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) } | |
| 213 | + diffRequests.clear() | |
| 214 | + deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) } | |
| 215 | + deviceRequests.clear() | |
| 216 | + terminals.values.forEach { it.onExit("The connection dropped.") } | |
| 217 | + terminals.clear() | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + private fun onMessage(message: ServerMessage) { | |
| 222 | + when (message) { | |
| 223 | + is ServerMessage.State -> { | |
| 224 | + projects.clear() | |
| 225 | + projects.addAll(message.projects) | |
| 226 | + val ids = message.threads.map { it.id }.toSet() | |
| 227 | + threads.keys.retainAll(ids) | |
| 228 | + message.threads.forEach { threads[it.id] = it } | |
| 229 | + open.values.forEach { thread -> message.threads.find { it.id == thread.id }?.let { thread.summary = it } } | |
| 230 | + } | |
| 231 | + is ServerMessage.Thread -> { | |
| 232 | + threads[message.summary.id] = message.summary | |
| 233 | + open.getOrPut(message.summary.id) { OpenThread(this, message.summary.id) }.apply(message) | |
| 234 | + } | |
| 235 | + is ServerMessage.Created -> createdCallbacks.remove(message.reference)?.invoke(message.thread) | |
| 236 | + is ServerMessage.Error -> app.showNotice(message.message, error = true) | |
| 237 | + is ServerMessage.Settings -> settings = message.values | |
| 238 | + is ServerMessage.Folders -> folderRequests.remove(message.path)?.complete(message) | |
| 239 | + is ServerMessage.ProjectAdded -> app.showNotice("Added ${message.path.substringAfterLast('/')}") | |
| 240 | + is ServerMessage.TerminalOutput -> terminals[message.terminal]?.onOutput( | |
| 241 | + android.util.Base64.decode(message.data, android.util.Base64.DEFAULT), | |
| 242 | + ) | |
| 243 | + is ServerMessage.TerminalExited -> terminals.remove(message.terminal)?.onExit(message.error) | |
| 244 | + is ServerMessage.DeviceReply -> deviceRequests.remove(message.request)?.complete(message) | |
| 245 | + is ServerMessage.DeviceState -> deviceState = message.state | |
| 246 | + is ServerMessage.DeviceOpened -> deviceEvents.toList().forEach { it(message.thread, message.device, null) } | |
| 247 | + is ServerMessage.DeviceClosed -> deviceEvents.toList().forEach { it(message.thread, null, message.device ?: "") } | |
| 248 | + is ServerMessage.Catalog -> { | |
| 249 | + instances = message.instances | |
| 250 | + favorites = message.favorites | |
| 251 | + } | |
| 252 | + is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message) | |
| 253 | + is ServerMessage.Welcome, is ServerMessage.Denied -> {} | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + // ---- threads --------------------------------------------------------------- | |
| 258 | + | |
| 259 | + /** Opens a thread: its transcript comes and stays current until [close]. */ | |
| 260 | + fun open(id: String): OpenThread { | |
| 261 | + val thread = open.getOrPut(id) { OpenThread(this, id) } | |
| 262 | + threads[id]?.let { thread.summary = it } | |
| 263 | + if (subscribed.add(id)) send(ClientMessage.Subscribe(id)) | |
| 264 | + return thread | |
| 265 | + } | |
| 266 | + | |
| 267 | + fun close(id: String) { | |
| 268 | + if (subscribed.remove(id)) send(ClientMessage.Unsubscribe(id)) | |
| 269 | + } | |
| 270 | + | |
| 271 | + fun act(thread: String, action: ThreadAction) { | |
| 272 | + if (!send(ClientMessage.Action(thread, action))) app.showNotice("$name is offline.", error = true) | |
| 273 | + } | |
| 274 | + | |
| 275 | + /** Starts a thread with its first message; [onCreated] gets its id once the host made it. */ | |
| 276 | + fun create(message: ClientMessage.Create, onCreated: (String) -> Unit) { | |
| 277 | + val reference = nextRequest++ | |
| 278 | + createdCallbacks[reference] = onCreated | |
| 279 | + if (!send(message.copy(reference = reference))) { | |
| 280 | + createdCallbacks.remove(reference) | |
| 281 | + app.showNotice("$name is offline.", error = true) | |
| 282 | + } | |
| 283 | + } | |
| 284 | + | |
| 285 | + fun updateSettings(patch: SettingsPatch) { | |
| 286 | + // Shown at once; the host sends the settings back as they ended up. | |
| 287 | + settings = JsonObject(settings + patch.values) | |
| 288 | + if (!send(ClientMessage.UpdateSettings(patch))) app.showNotice("$name is offline.", error = true) | |
| 289 | + } | |
| 290 | + | |
| 291 | + fun setting(key: String): JsonElement? = settings[key] | |
| 292 | + | |
| 293 | + fun refreshCatalog() = send(ClientMessage.Catalog) | |
| 294 | + | |
| 295 | + fun model(instance: String, id: String?): RemoteModel? { | |
| 296 | + // Threads saved before instances existed name none; any instance's model will do then. | |
| 297 | + val models = instances.find { it.id == instance }?.models ?: instances.flatMap { it.models } | |
| 298 | + return models.find { it.id == id } ?: models.find { it.resolved == id && !it.legacy } ?: models.find { it.resolved == id } | |
| 299 | + } | |
| 300 | + | |
| 301 | + // ---- requests -------------------------------------------------------------- | |
| 302 | + | |
| 303 | + suspend fun listFolders(path: String): ServerMessage.Folders { | |
| 304 | + val reply = CompletableDeferred<ServerMessage.Folders>() | |
| 305 | + folderRequests[path]?.complete(ServerMessage.Folders(path = path, error = "Asked again.")) | |
| 306 | + folderRequests[path] = reply | |
| 307 | + if (!send(ClientMessage.ListFolders(path))) { | |
| 308 | + folderRequests.remove(path) | |
| 309 | + return ServerMessage.Folders(path = path, error = "$name is offline.") | |
| 310 | + } | |
| 311 | + return reply.await() | |
| 312 | + } | |
| 313 | + | |
| 314 | + suspend fun diff(thread: String, base: String, head: String, path: String?, ignoreWhitespace: Boolean): ServerMessage.DiffReply { | |
| 315 | + val request = nextRequest++ | |
| 316 | + val reply = CompletableDeferred<ServerMessage.DiffReply>() | |
| 317 | + diffRequests[request] = reply | |
| 318 | + if (!send(ClientMessage.Diff(request, thread, base, head, path, ignoreWhitespace))) { | |
| 319 | + diffRequests.remove(request) | |
| 320 | + return ServerMessage.DiffReply(request, error = "$name is offline.") | |
| 321 | + } | |
| 322 | + return reply.await() | |
| 323 | + } | |
| 324 | + | |
| 325 | + /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */ | |
| 326 | + suspend fun device(call: JsonObject): ServerMessage.DeviceReply { | |
| 327 | + val request = nextRequest++ | |
| 328 | + val reply = CompletableDeferred<ServerMessage.DeviceReply>() | |
| 329 | + deviceRequests[request] = reply | |
| 330 | + if (!send(ClientMessage.Device(request, call))) { | |
| 331 | + deviceRequests.remove(request) | |
| 332 | + return ServerMessage.DeviceReply(request, error = "$name is offline.") | |
| 333 | + } | |
| 334 | + return reply.await() | |
| 335 | + } | |
| 336 | + | |
| 337 | + fun deviceCall(name: String, vararg fields: Pair<String, JsonElement>): JsonObject = | |
| 338 | + JsonObject(mapOf("call" to JsonPrimitive(name)) + fields) | |
| 339 | + | |
| 340 | + // ---- terminals ------------------------------------------------------------- | |
| 341 | + | |
| 342 | + /** Starts a shell on the host in a thread's folder; returns its id, or `null` while offline. */ | |
| 343 | + fun openTerminal(thread: String?, project: String, columns: Int, lines: Int, listener: TerminalListener): Long? { | |
| 344 | + val terminal = nextRequest++ | |
| 345 | + terminals[terminal] = listener | |
| 346 | + if (!send(ClientMessage.TerminalOpen(terminal, thread, project, columns, lines))) { | |
| 347 | + terminals.remove(terminal) | |
| 348 | + return null | |
| 349 | + } | |
| 350 | + return terminal | |
| 351 | + } | |
| 352 | + | |
| 353 | + fun terminalInput(terminal: Long, bytes: ByteArray) { | |
| 354 | + send(ClientMessage.TerminalInput(terminal, android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP))) | |
| 355 | + } | |
| 356 | + | |
| 357 | + fun resizeTerminal(terminal: Long, columns: Int, lines: Int) = send(ClientMessage.TerminalResize(terminal, columns, lines)) | |
| 358 | + | |
| 359 | + fun closeTerminal(terminal: Long) { | |
| 360 | + terminals.remove(terminal) | |
| 361 | + send(ClientMessage.TerminalClose(terminal)) | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | + interface TerminalListener { | |
| 366 | + fun onOutput(bytes: ByteArray) | |
| 367 | + | |
| 368 | + fun onExit(error: String?) | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** A thread that's open, with its transcript kept current by the host. */ | |
| 372 | + class OpenThread(val host: HostState, val id: String) { | |
| 373 | + var summary by mutableStateOf(host.threads[id] ?: ThreadSummary(id = id)) | |
| 374 | + internal set | |
| 375 | + var detail by mutableStateOf(ThreadDetail()) | |
| 376 | + private set | |
| 377 | + val items = mutableStateListOf<SavedItem>() | |
| 378 | + | |
| 379 | + /** The host sent the transcript at least once. */ | |
| 380 | + var loaded by mutableStateOf(false) | |
| 381 | + private set | |
| 382 | + | |
| 383 | + val ref get() = ThreadRef(host.saved.id, id) | |
| 384 | + | |
| 385 | + internal fun apply(message: ServerMessage.Thread) { | |
| 386 | + summary = message.summary | |
| 387 | + detail = message.detail | |
| 388 | + val from = message.from.coerceAtMost(items.size) | |
| 389 | + // Items before `from` are as last sent; the rest are replaced. | |
| 390 | + if (from < items.size) items.removeRange(from, items.size) | |
| 391 | + items.addAll(message.items) | |
| 392 | + if (items.size > message.len) items.removeRange(message.len, items.size) | |
| 393 | + loaded = true | |
| 394 | + } | |
| 395 | + | |
| 396 | + fun act(action: ThreadAction) = host.act(id, action) | |
| 397 | + | |
| 398 | + val working: Boolean get() = summary.status == "working" || detail.thinking | |
| 399 | + | |
| 400 | + /** The project this thread runs in, if the host lists it. */ | |
| 401 | + val project: ProjectInfo? get() = host.projects.find { it.path == summary.project } | |
| 402 | + } | |
| 403 | + | |
| 404 | + /** The Folders reply's subfolders, for pickers. */ | |
| 405 | + typealias Folders = List<Folder> | |
| 406 | + | |
| 407 | + /** Patches with one value. */ | |
| 408 | + fun patchOf(key: String, value: JsonElement) = SettingsPatch(values = JsonObject(mapOf(key to value))) | |
| 409 | + | |
| 410 | + /** One diff, as fetched. */ | |
| 411 | + typealias FileDiffs = List<RemoteFileDiff> | |
| @@ -0,0 +1,88 @@ | ||
| 1 | + package cc.vectrum.code.data | |
| 2 | + | |
| 3 | + import android.content.SharedPreferences | |
| 4 | + import androidx.compose.runtime.getValue | |
| 5 | + import androidx.compose.runtime.mutableStateOf | |
| 6 | + import androidx.compose.runtime.setValue | |
| 7 | + import cc.vectrum.code.net.WireJson | |
| 8 | + import kotlinx.serialization.json.JsonElement | |
| 9 | + import kotlinx.serialization.json.JsonObject | |
| 10 | + import kotlinx.serialization.json.JsonPrimitive | |
| 11 | + import kotlinx.serialization.json.booleanOrNull | |
| 12 | + import kotlinx.serialization.json.jsonObject | |
| 13 | + import kotlinx.serialization.json.jsonPrimitive | |
| 14 | + | |
| 15 | + /** | |
| 16 | + * This phone's own preferences: what the desktop calls device preferences | |
| 17 | + * (appearance, conversation, confirmations), under the desktop's setting | |
| 18 | + * keys and with its defaults. Environment settings belong to the host and | |
| 19 | + * come over the connection instead. | |
| 20 | + */ | |
| 21 | + class DevicePrefs(private val store: SharedPreferences) { | |
| 22 | + private var values by mutableStateOf(load()) | |
| 23 | + | |
| 24 | + private fun load(): JsonObject = store.getString("prefs", null) | |
| 25 | + ?.let { runCatching { WireJson.parseToJsonElement(it).jsonObject }.getOrNull() } | |
| 26 | + ?: JsonObject(emptyMap()) | |
| 27 | + | |
| 28 | + operator fun get(key: String): JsonElement? = values[key] | |
| 29 | + | |
| 30 | + fun string(key: String): String = values[key]?.jsonPrimitive?.content ?: DEFAULTS.getValue(key).toString() | |
| 31 | + | |
| 32 | + fun bool(key: String): Boolean = values[key]?.jsonPrimitive?.booleanOrNull ?: (DEFAULTS[key] as? Boolean ?: false) | |
| 33 | + | |
| 34 | + fun set(key: String, value: String) = put(key, JsonPrimitive(value)) | |
| 35 | + | |
| 36 | + fun set(key: String, value: Boolean) = put(key, JsonPrimitive(value)) | |
| 37 | + | |
| 38 | + /** Whether a preference differs from its default, for the settings' reset buttons. */ | |
| 39 | + fun changed(key: String): Boolean = key in values && values[key]?.jsonPrimitive?.content != DEFAULTS[key].toString() | |
| 40 | + | |
| 41 | + fun reset(key: String) { | |
| 42 | + values = JsonObject(values - key) | |
| 43 | + save() | |
| 44 | + } | |
| 45 | + | |
| 46 | + private fun put(key: String, value: JsonElement) { | |
| 47 | + values = JsonObject(values + (key to value)) | |
| 48 | + save() | |
| 49 | + } | |
| 50 | + | |
| 51 | + private fun save() = store.edit().putString("prefs", values.toString()).apply() | |
| 52 | + | |
| 53 | + // The ones read everywhere, by name. | |
| 54 | + /** `system`, `light` or `dark`. */ | |
| 55 | + val themeMode get() = string("theme_mode") | |
| 56 | + val lightTheme get() = string("light_theme") | |
| 57 | + val darkTheme get() = string("dark_theme") | |
| 58 | + | |
| 59 | + companion object { | |
| 60 | + /** The desktop's defaults (src/settings.rs), as JSON scalars. */ | |
| 61 | + val DEFAULTS: Map<String, Any> = mapOf( | |
| 62 | + "theme_mode" to "system", | |
| 63 | + "light_theme" to "bone", | |
| 64 | + "dark_theme" to "graphite", | |
| 65 | + "diff_colors" to "red_green", | |
| 66 | + "time_format" to "system", | |
| 67 | + "fold_turns" to true, | |
| 68 | + "show_costs" to true, | |
| 69 | + "follow_up" to "queue", | |
| 70 | + "streaming" to "paragraphs", | |
| 71 | + "slash_skills" to true, | |
| 72 | + "inline_ui" to true, | |
| 73 | + "hide_whitespace" to true, | |
| 74 | + "diff_collapsed" to true, | |
| 75 | + "diff_side_by_side" to false, | |
| 76 | + "composer_context" to false, | |
| 77 | + "plan_mode" to false, | |
| 78 | + "context_meter" to false, | |
| 79 | + "confirm_unpin" to false, | |
| 80 | + "confirm_archive" to false, | |
| 81 | + "confirm_settle" to false, | |
| 82 | + "confirm_delete" to true, | |
| 83 | + "thread_notifications" to "notifications", | |
| 84 | + "working_section" to false, | |
| 85 | + "show_settled" to true, | |
| 86 | + ) | |
| 87 | + } | |
| 88 | + } | |
| @@ -0,0 +1,138 @@ | ||
| 1 | + package cc.vectrum.code.net | |
| 2 | + | |
| 3 | + import android.os.Handler | |
| 4 | + import android.os.Looper | |
| 5 | + import okhttp3.OkHttpClient | |
| 6 | + import okhttp3.Request | |
| 7 | + import okhttp3.Response | |
| 8 | + import okhttp3.WebSocket | |
| 9 | + import okhttp3.WebSocketListener | |
| 10 | + import java.util.concurrent.TimeUnit | |
| 11 | + | |
| 12 | + /** Where a connection to a host stands. */ | |
| 13 | + sealed interface ConnectionStatus { | |
| 14 | + data object Connecting : ConnectionStatus | |
| 15 | + | |
| 16 | + data class Connected(val host: String, val version: String) : ConnectionStatus | |
| 17 | + | |
| 18 | + /** The host turned us away (a wrong key, another protocol); no retrying. */ | |
| 19 | + data class Denied(val reason: String) : ConnectionStatus | |
| 20 | + | |
| 21 | + /** Lost or never made; retried after a while. */ | |
| 22 | + data class Offline(val reason: String) : ConnectionStatus | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** | |
| 26 | + * One WebSocket to a host, kept up: it says hello with the key, hands every | |
| 27 | + * message to [onMessage] on the main thread, and reconnects with a backoff | |
| 28 | + * when the connection drops. | |
| 29 | + */ | |
| 30 | + class Connection( | |
| 31 | + private val address: String, | |
| 32 | + private val key: String, | |
| 33 | + private val deviceName: String, | |
| 34 | + private val onStatus: (ConnectionStatus) -> Unit, | |
| 35 | + private val onMessage: (ServerMessage) -> Unit, | |
| 36 | + ) { | |
| 37 | + private val main = Handler(Looper.getMainLooper()) | |
| 38 | + private var socket: WebSocket? = null | |
| 39 | + private var welcomed = false | |
| 40 | + private var stopped = false | |
| 41 | + private var attempt = 0 | |
| 42 | + private var generation = 0 | |
| 43 | + | |
| 44 | + fun start() { | |
| 45 | + stopped = false | |
| 46 | + open() | |
| 47 | + } | |
| 48 | + | |
| 49 | + fun stop() { | |
| 50 | + stopped = true | |
| 51 | + generation++ | |
| 52 | + main.removeCallbacksAndMessages(null) | |
| 53 | + socket?.close(1000, "bye") | |
| 54 | + socket = null | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** Drops the connection and dials again at once, as after the network comes back. */ | |
| 58 | + fun reconnect() { | |
| 59 | + if (stopped) return | |
| 60 | + generation++ | |
| 61 | + main.removeCallbacksAndMessages(null) | |
| 62 | + socket?.cancel() | |
| 63 | + socket = null | |
| 64 | + attempt = 0 | |
| 65 | + open() | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** Sends once welcomed; messages before that are dropped like the desktop drops them. */ | |
| 69 | + fun send(message: ClientMessage): Boolean { | |
| 70 | + val socket = socket ?: return false | |
| 71 | + if (!welcomed) return false | |
| 72 | + return socket.send(encode(message)) | |
| 73 | + } | |
| 74 | + | |
| 75 | + val connected: Boolean get() = welcomed && socket != null | |
| 76 | + | |
| 77 | + private fun open() { | |
| 78 | + val gen = ++generation | |
| 79 | + welcomed = false | |
| 80 | + onStatus(ConnectionStatus.Connecting) | |
| 81 | + val request = Request.Builder().url("ws://${withPort(address)}/").build() | |
| 82 | + socket = client.newWebSocket( | |
| 83 | + request, | |
| 84 | + object : WebSocketListener() { | |
| 85 | + override fun onOpen(webSocket: WebSocket, response: Response) { | |
| 86 | + webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName))) | |
| 87 | + } | |
| 88 | + | |
| 89 | + override fun onMessage(webSocket: WebSocket, text: String) { | |
| 90 | + val message = parseServerMessage(text) ?: return | |
| 91 | + main.post { | |
| 92 | + if (gen != generation) return@post | |
| 93 | + when (message) { | |
| 94 | + is ServerMessage.Welcome -> { | |
| 95 | + welcomed = true | |
| 96 | + attempt = 0 | |
| 97 | + onStatus(ConnectionStatus.Connected(message.host, message.version)) | |
| 98 | + } | |
| 99 | + is ServerMessage.Denied -> { | |
| 100 | + stopped = true | |
| 101 | + onStatus(ConnectionStatus.Denied(message.reason)) | |
| 102 | + } | |
| 103 | + else -> onMessage(message) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = | |
| 109 | + dropped(gen, reason.ifBlank { "The host closed the connection." }) | |
| 110 | + | |
| 111 | + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = | |
| 112 | + dropped(gen, t.message ?: t.javaClass.simpleName) | |
| 113 | + }, | |
| 114 | + ) | |
| 115 | + } | |
| 116 | + | |
| 117 | + private fun dropped(gen: Int, reason: String) { | |
| 118 | + main.post { | |
| 119 | + if (gen != generation || stopped) return@post | |
| 120 | + welcomed = false | |
| 121 | + socket = null | |
| 122 | + onStatus(ConnectionStatus.Offline(reason)) | |
| 123 | + val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)] | |
| 124 | + attempt++ | |
| 125 | + main.postDelayed({ if (gen == generation && !stopped) open() }, delay) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + companion object { | |
| 130 | + private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000) | |
| 131 | + | |
| 132 | + private val client = OkHttpClient.Builder() | |
| 133 | + .connectTimeout(8, TimeUnit.SECONDS) | |
| 134 | + .readTimeout(0, TimeUnit.SECONDS) | |
| 135 | + .pingInterval(0, TimeUnit.SECONDS) | |
| 136 | + .build() | |
| 137 | + } | |
| 138 | + } | |
| @@ -0,0 +1,542 @@ | ||
| 1 | + @file:OptIn(ExperimentalSerializationApi::class) | |
| 2 | + | |
| 3 | + package cc.vectrum.code.net | |
| 4 | + | |
| 5 | + import kotlinx.serialization.ExperimentalSerializationApi | |
| 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.JsonClassDiscriminator | |
| 11 | + import kotlinx.serialization.json.JsonElement | |
| 12 | + import kotlinx.serialization.json.JsonNamingStrategy | |
| 13 | + import kotlinx.serialization.json.JsonNull | |
| 14 | + import kotlinx.serialization.json.JsonObject | |
| 15 | + import kotlinx.serialization.json.decodeFromJsonElement | |
| 16 | + import kotlinx.serialization.json.jsonObject | |
| 17 | + import kotlinx.serialization.json.jsonPrimitive | |
| 18 | + import kotlinx.serialization.json.longOrNull | |
| 19 | + | |
| 20 | + // The desktop's remote protocol (src/remote/mod.rs): JSON over WebSocket text | |
| 21 | + // messages, enums tagged by `type`, fields in snake_case. A phone is a client | |
| 22 | + // like another desktop: it says hello with the host's key, gets the projects | |
| 23 | + // and threads, subscribes to the threads it opens and acts on them. | |
| 24 | + | |
| 25 | + /** The port a host listens on unless told otherwise. */ | |
| 26 | + const val DEFAULT_PORT = 47823 | |
| 27 | + | |
| 28 | + /** Bumped by the desktop when the messages change incompatibly. */ | |
| 29 | + const val PROTOCOL = 1 | |
| 30 | + | |
| 31 | + val WireJson = Json { | |
| 32 | + ignoreUnknownKeys = true | |
| 33 | + explicitNulls = false | |
| 34 | + encodeDefaults = true | |
| 35 | + coerceInputValues = true | |
| 36 | + namingStrategy = JsonNamingStrategy.SnakeCase | |
| 37 | + classDiscriminator = "type" | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** A thread as the sidebar needs it. Times are milliseconds since the epoch. */ | |
| 41 | + @Serializable | |
| 42 | + data class ThreadSummary( | |
| 43 | + val id: String = "", | |
| 44 | + val project: String = "", | |
| 45 | + val title: String = "", | |
| 46 | + val provider: String = "claude", | |
| 47 | + val instance: String = "", | |
| 48 | + val model: String? = null, | |
| 49 | + val permissionMode: String = "default", | |
| 50 | + val reasoning: String = "default", | |
| 51 | + val fastMode: Boolean = false, | |
| 52 | + /** `starting`, `idle`, `working` or `exited`. */ | |
| 53 | + val status: String = "idle", | |
| 54 | + /** A tool call or question waits for the user. */ | |
| 55 | + val attention: Boolean = false, | |
| 56 | + val settled: Boolean = false, | |
| 57 | + val pinned: Boolean = false, | |
| 58 | + val snoozedUntil: Long? = null, | |
| 59 | + val created: Long = 0, | |
| 60 | + val updated: Long = 0, | |
| 61 | + val lastMessage: Long = 0, | |
| 62 | + val branch: String? = null, | |
| 63 | + val totalCost: Double = 0.0, | |
| 64 | + val hasItems: Boolean = false, | |
| 65 | + /** For a subagent's thread, the thread that delegated its task. */ | |
| 66 | + val parent: String? = null, | |
| 67 | + ) | |
| 68 | + | |
| 69 | + @Serializable | |
| 70 | + data class QueuedMessage(val text: String = "", val attachments: List<String> = emptyList()) | |
| 71 | + | |
| 72 | + @Serializable | |
| 73 | + data class PullRequest(val number: Long = 0, val url: String = "", val state: String = "OPEN") | |
| 74 | + | |
| 75 | + @Serializable | |
| 76 | + data class FileChange(val path: String = "", val added: Long? = null, val removed: Long? = null) | |
| 77 | + | |
| 78 | + @Serializable | |
| 79 | + data class TurnChanges(val base: String = "", val head: String = "", val files: List<FileChange> = emptyList()) { | |
| 80 | + val added get() = files.sumOf { it.added ?: 0 } | |
| 81 | + val removed get() = files.sumOf { it.removed ?: 0 } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** A command, monitor or subagent the host's agent left running. */ | |
| 85 | + @Serializable | |
| 86 | + data class RemoteTask(val taskId: String = "", val description: String = "", val elapsedMs: Long = 0) | |
| 87 | + | |
| 88 | + /** The rest of an open thread's state, sent with its transcript. */ | |
| 89 | + @Serializable | |
| 90 | + data class ThreadDetail( | |
| 91 | + val cwd: String = "", | |
| 92 | + val activeModel: String? = null, | |
| 93 | + val thinking: Boolean = false, | |
| 94 | + val preparing: Boolean = false, | |
| 95 | + val turnElapsedMs: Long? = null, | |
| 96 | + val contextTokens: Long? = null, | |
| 97 | + val contextWindow: Long? = null, | |
| 98 | + val queue: List<QueuedMessage> = emptyList(), | |
| 99 | + val limitResets: Long? = null, | |
| 100 | + val resumeAt: Long? = null, | |
| 101 | + val pendingWorktree: Boolean = false, | |
| 102 | + val pullRequest: PullRequest? = null, | |
| 103 | + /** `[turn end index, changes]` pairs. */ | |
| 104 | + val turnChanges: List<JsonArray> = emptyList(), | |
| 105 | + val backgroundTasks: List<RemoteTask> = emptyList(), | |
| 106 | + ) { | |
| 107 | + /** What each finished turn changed, by its `TurnEnd` item's index. */ | |
| 108 | + fun changesByTurn(): Map<Int, TurnChanges> = turnChanges.mapNotNull { pair -> | |
| 109 | + val ix = pair.getOrNull(0)?.jsonPrimitive?.longOrNull?.toInt() ?: return@mapNotNull null | |
| 110 | + val changes = pair.getOrNull(1)?.let { WireJson.decodeFromJsonElement<TurnChanges>(it) } ?: return@mapNotNull null | |
| 111 | + ix to changes | |
| 112 | + }.toMap() | |
| 113 | + } | |
| 114 | + | |
| 115 | + @Serializable | |
| 116 | + data class ProjectInfo( | |
| 117 | + val path: String = "", | |
| 118 | + val name: String = "", | |
| 119 | + /** `null` outside a git repository. */ | |
| 120 | + val branch: String? = null, | |
| 121 | + val branches: List<String> = emptyList(), | |
| 122 | + val git: Boolean = false, | |
| 123 | + val added: Long = 0, | |
| 124 | + val removed: Long = 0, | |
| 125 | + ) | |
| 126 | + | |
| 127 | + /** One agent among several a call started. */ | |
| 128 | + @Serializable | |
| 129 | + data class SubagentMember( | |
| 130 | + val id: String = "", | |
| 131 | + val label: String = "", | |
| 132 | + val kind: String? = null, | |
| 133 | + /** `running`, `completed`, `failed` or `stopped`. */ | |
| 134 | + val status: String = "running", | |
| 135 | + val started: Long = 0, | |
| 136 | + val finished: Long? = null, | |
| 137 | + val tokens: Long? = null, | |
| 138 | + val toolUses: Int = 0, | |
| 139 | + /** `[tool name, input]`. */ | |
| 140 | + val tool: JsonArray? = null, | |
| 141 | + val summary: String? = null, | |
| 142 | + ) | |
| 143 | + | |
| 144 | + @Serializable | |
| 145 | + data class SubagentProgress( | |
| 146 | + val started: Long = 0, | |
| 147 | + val finished: Long? = null, | |
| 148 | + val tokens: Long? = null, | |
| 149 | + /** Its latest tool call: `[tool name, input]`. */ | |
| 150 | + val tool: JsonArray? = null, | |
| 151 | + val summary: String? = null, | |
| 152 | + val members: List<SubagentMember> = emptyList(), | |
| 153 | + ) | |
| 154 | + | |
| 155 | + /** One entry of a transcript, as the host saves and sends it. */ | |
| 156 | + @Serializable | |
| 157 | + sealed class SavedItem { | |
| 158 | + @Serializable | |
| 159 | + @SerialName("user") | |
| 160 | + data class User(val text: String = "", val attachments: List<String> = emptyList(), val at: Long = 0) : SavedItem() | |
| 161 | + | |
| 162 | + @Serializable | |
| 163 | + @SerialName("assistant") | |
| 164 | + data class Assistant(val text: String = "") : SavedItem() | |
| 165 | + | |
| 166 | + @Serializable | |
| 167 | + @SerialName("tool") | |
| 168 | + data class Tool( | |
| 169 | + val id: String = "", | |
| 170 | + val name: String = "", | |
| 171 | + val input: JsonElement = JsonNull, | |
| 172 | + val result: String? = null, | |
| 173 | + /** `done`, `running` or `error`. */ | |
| 174 | + val state: String = "done", | |
| 175 | + /** `pending`, `allowed`, `denied` or `cancelled`, if it asked for permission. */ | |
| 176 | + val permission: String? = null, | |
| 177 | + val suggestions: List<JsonElement> = emptyList(), | |
| 178 | + val subagentToolCount: Int = 0, | |
| 179 | + /** An AskUserQuestion form's answers so far, one per question. */ | |
| 180 | + val answers: List<String?>? = null, | |
| 181 | + /** `running`, `completed`, `failed` or `stopped`, if it ran in the background. */ | |
| 182 | + val background: String? = null, | |
| 183 | + val subagent: SubagentProgress? = null, | |
| 184 | + ) : SavedItem() | |
| 185 | + | |
| 186 | + @Serializable | |
| 187 | + @SerialName("notice") | |
| 188 | + data class Notice(val error: Boolean = false, val text: String = "") : SavedItem() | |
| 189 | + | |
| 190 | + @Serializable | |
| 191 | + @SerialName("task_event") | |
| 192 | + data class TaskEvent(val summary: String = "", val status: String = "") : SavedItem() | |
| 193 | + | |
| 194 | + @Serializable | |
| 195 | + @SerialName("question") | |
| 196 | + data class Question( | |
| 197 | + val title: String = "", | |
| 198 | + val message: String? = null, | |
| 199 | + val options: List<String> = emptyList(), | |
| 200 | + val confirm: Boolean = false, | |
| 201 | + val answer: String? = null, | |
| 202 | + ) : SavedItem() | |
| 203 | + | |
| 204 | + @Serializable | |
| 205 | + @SerialName("turn_end") | |
| 206 | + data class TurnEnd(val durationMs: Long = 0, val cost: Double? = null, val at: Long = 0) : SavedItem() | |
| 207 | + } | |
| 208 | + | |
| 209 | + /** A file sent along with a message, base64. */ | |
| 210 | + @Serializable | |
| 211 | + data class Upload(val name: String, val data: String) | |
| 212 | + | |
| 213 | + /** What a client asks of a thread on the host. */ | |
| 214 | + @Serializable | |
| 215 | + @JsonClassDiscriminator("action") | |
| 216 | + sealed class ThreadAction { | |
| 217 | + @Serializable @SerialName("send") | |
| 218 | + data class Send(val text: String, val attachments: List<Upload> = emptyList()) : ThreadAction() | |
| 219 | + | |
| 220 | + @Serializable @SerialName("queue") | |
| 221 | + data class Queue(val text: String, val attachments: List<Upload> = emptyList()) : ThreadAction() | |
| 222 | + | |
| 223 | + @Serializable @SerialName("steer_queued") | |
| 224 | + data class SteerQueued(val ix: Int) : ThreadAction() | |
| 225 | + | |
| 226 | + @Serializable @SerialName("remove_queued") | |
| 227 | + data class RemoveQueued(val ix: Int) : ThreadAction() | |
| 228 | + | |
| 229 | + @Serializable @SerialName("interrupt") | |
| 230 | + data object Interrupt : ThreadAction() | |
| 231 | + | |
| 232 | + /** `decision`: `allow`, `always` or `deny`. */ | |
| 233 | + @Serializable @SerialName("permission") | |
| 234 | + data class Permission(val ix: Int, val decision: String) : ThreadAction() | |
| 235 | + | |
| 236 | + @Serializable @SerialName("answer") | |
| 237 | + data class Answer(val ix: Int, val option: Int) : ThreadAction() | |
| 238 | + | |
| 239 | + /** Answers an AskUserQuestion form, one answer per question. */ | |
| 240 | + @Serializable @SerialName("ask_submit") | |
| 241 | + data class AskSubmit(val ix: Int, val answers: List<String>) : ThreadAction() | |
| 242 | + | |
| 243 | + @Serializable @SerialName("ask_skip") | |
| 244 | + data class AskSkip(val ix: Int) : ThreadAction() | |
| 245 | + | |
| 246 | + /** Sends a running command or subagent to the background. */ | |
| 247 | + @Serializable @SerialName("background") | |
| 248 | + data class Background(val ix: Int) : ThreadAction() | |
| 249 | + | |
| 250 | + @Serializable @SerialName("stop_task") | |
| 251 | + data class StopTask(val taskId: String) : ThreadAction() | |
| 252 | + | |
| 253 | + @Serializable @SerialName("set_model") | |
| 254 | + data class SetModel(val model: String?) : ThreadAction() | |
| 255 | + | |
| 256 | + @Serializable @SerialName("set_permission_mode") | |
| 257 | + data class SetPermissionMode(val mode: String) : ThreadAction() | |
| 258 | + | |
| 259 | + @Serializable @SerialName("set_reasoning") | |
| 260 | + data class SetReasoning(val reasoning: String) : ThreadAction() | |
| 261 | + | |
| 262 | + @Serializable @SerialName("set_fast_mode") | |
| 263 | + data class SetFastMode(val on: Boolean) : ThreadAction() | |
| 264 | + | |
| 265 | + @Serializable @SerialName("settle") | |
| 266 | + data object Settle : ThreadAction() | |
| 267 | + | |
| 268 | + @Serializable @SerialName("unsettle") | |
| 269 | + data object Unsettle : ThreadAction() | |
| 270 | + | |
| 271 | + @Serializable @SerialName("pin") | |
| 272 | + data class Pin(val pinned: Boolean) : ThreadAction() | |
| 273 | + | |
| 274 | + @Serializable @SerialName("snooze") | |
| 275 | + data class Snooze(val until: Long?) : ThreadAction() | |
| 276 | + | |
| 277 | + @Serializable @SerialName("rename") | |
| 278 | + data class Rename(val title: String) : ThreadAction() | |
| 279 | + | |
| 280 | + @Serializable @SerialName("cancel_resume") | |
| 281 | + data object CancelResume : ThreadAction() | |
| 282 | + | |
| 283 | + @Serializable @SerialName("schedule_resume") | |
| 284 | + data class ScheduleResume(val at: Long) : ThreadAction() | |
| 285 | + | |
| 286 | + @Serializable @SerialName("delete") | |
| 287 | + data object Delete : ThreadAction() | |
| 288 | + } | |
| 289 | + | |
| 290 | + /** What the Add project palette picked. */ | |
| 291 | + @Serializable | |
| 292 | + @JsonClassDiscriminator("kind") | |
| 293 | + sealed class ProjectSource { | |
| 294 | + @Serializable @SerialName("folder") | |
| 295 | + data class Folder(val path: String) : ProjectSource() | |
| 296 | + | |
| 297 | + @Serializable @SerialName("new") | |
| 298 | + data class New(val name: String) : ProjectSource() | |
| 299 | + | |
| 300 | + @Serializable @SerialName("url") | |
| 301 | + data class Url(val url: String) : ProjectSource() | |
| 302 | + | |
| 303 | + /** `forge`: `git_hub`, `git_lab`, `azure_dev_ops`, `bitbucket` or `forgejo`. */ | |
| 304 | + @Serializable @SerialName("forge") | |
| 305 | + data class Forge(val forge: String, val repo: String) : ProjectSource() | |
| 306 | + } | |
| 307 | + | |
| 308 | + /** Changes to the host's environment settings or its projects' overrides. */ | |
| 309 | + @Serializable | |
| 310 | + data class SettingsPatch( | |
| 311 | + val values: JsonObject = JsonObject(emptyMap()), | |
| 312 | + val projects: Map<String, JsonObject> = emptyMap(), | |
| 313 | + ) | |
| 314 | + | |
| 315 | + @Serializable | |
| 316 | + sealed class ClientMessage { | |
| 317 | + @Serializable @SerialName("hello") | |
| 318 | + data class Hello(val key: String, val name: String, val protocol: Int = PROTOCOL) : ClientMessage() | |
| 319 | + | |
| 320 | + @Serializable @SerialName("subscribe") | |
| 321 | + data class Subscribe(val thread: String) : ClientMessage() | |
| 322 | + | |
| 323 | + @Serializable @SerialName("unsubscribe") | |
| 324 | + data class Unsubscribe(val thread: String) : ClientMessage() | |
| 325 | + | |
| 326 | + @Serializable @SerialName("action") | |
| 327 | + data class Action(val thread: String, val action: ThreadAction) : ClientMessage() | |
| 328 | + | |
| 329 | + /** Starts a thread in one of the host's projects with its first message. */ | |
| 330 | + @Serializable @SerialName("create") | |
| 331 | + data class Create( | |
| 332 | + val reference: Long, | |
| 333 | + val project: String, | |
| 334 | + val instance: String, | |
| 335 | + val provider: String, | |
| 336 | + val model: String?, | |
| 337 | + val permissionMode: String, | |
| 338 | + val reasoning: String, | |
| 339 | + val fastMode: Boolean, | |
| 340 | + val worktree: Boolean, | |
| 341 | + val base: String?, | |
| 342 | + val text: String, | |
| 343 | + val attachments: List<Upload> = emptyList(), | |
| 344 | + ) : ClientMessage() | |
| 345 | + | |
| 346 | + @Serializable @SerialName("update_settings") | |
| 347 | + data class UpdateSettings(val patch: SettingsPatch) : ClientMessage() | |
| 348 | + | |
| 349 | + /** Lists a folder on the host, as typed: `~/Work/`. */ | |
| 350 | + @Serializable @SerialName("list_folders") | |
| 351 | + data class ListFolders(val path: String) : ClientMessage() | |
| 352 | + | |
| 353 | + @Serializable @SerialName("add_project") | |
| 354 | + data class AddProject(val source: ProjectSource) : ClientMessage() | |
| 355 | + | |
| 356 | + @Serializable @SerialName("terminal_open") | |
| 357 | + data class TerminalOpen(val terminal: Long, val thread: String?, val project: String, val columns: Int, val lines: Int) : ClientMessage() | |
| 358 | + | |
| 359 | + /** Typed bytes, base64. */ | |
| 360 | + @Serializable @SerialName("terminal_input") | |
| 361 | + data class TerminalInput(val terminal: Long, val data: String) : ClientMessage() | |
| 362 | + | |
| 363 | + @Serializable @SerialName("terminal_resize") | |
| 364 | + data class TerminalResize(val terminal: Long, val columns: Int, val lines: Int) : ClientMessage() | |
| 365 | + | |
| 366 | + @Serializable @SerialName("terminal_close") | |
| 367 | + data class TerminalClose(val terminal: Long) : ClientMessage() | |
| 368 | + | |
| 369 | + /** Something for the host's devices; `call` is a `DeviceCall` (tagged by `call`). */ | |
| 370 | + @Serializable @SerialName("device") | |
| 371 | + data class Device(val request: Long, val call: JsonObject) : ClientMessage() | |
| 372 | + | |
| 373 | + /** The host's agents and their models. Older hosts don't answer. */ | |
| 374 | + @Serializable @SerialName("catalog") | |
| 375 | + data object Catalog : ClientMessage() | |
| 376 | + | |
| 377 | + /** The diff between two of a thread's snapshots. Older hosts don't answer. */ | |
| 378 | + @Serializable @SerialName("diff") | |
| 379 | + data class Diff( | |
| 380 | + val request: Long, | |
| 381 | + val thread: String, | |
| 382 | + val base: String, | |
| 383 | + val head: String, | |
| 384 | + val path: String?, | |
| 385 | + val ignoreWhitespace: Boolean, | |
| 386 | + ) : ClientMessage() | |
| 387 | + } | |
| 388 | + | |
| 389 | + @Serializable | |
| 390 | + data class Folder(val name: String = "", val git: Boolean = false) | |
| 391 | + | |
| 392 | + @Serializable | |
| 393 | + data class RemoteModel( | |
| 394 | + val id: String = "", | |
| 395 | + val resolved: String = "", | |
| 396 | + val name: String = "", | |
| 397 | + val detail: String = "", | |
| 398 | + val legacy: Boolean = false, | |
| 399 | + val efforts: List<String> = emptyList(), | |
| 400 | + val defaultEffort: String? = null, | |
| 401 | + val ultracode: Boolean = false, | |
| 402 | + val fastMode: Boolean = false, | |
| 403 | + val autoMode: Boolean = false, | |
| 404 | + val contextWindow: Long? = null, | |
| 405 | + val hidden: Boolean = false, | |
| 406 | + ) | |
| 407 | + | |
| 408 | + @Serializable | |
| 409 | + data class RemoteCommand( | |
| 410 | + val name: String = "", | |
| 411 | + val description: String = "", | |
| 412 | + val argumentHint: String = "", | |
| 413 | + val skill: Boolean = false, | |
| 414 | + ) | |
| 415 | + | |
| 416 | + /** One agent instance on the host, say a second Claude account. */ | |
| 417 | + @Serializable | |
| 418 | + data class RemoteInstance( | |
| 419 | + val id: String = "", | |
| 420 | + val provider: String = "claude", | |
| 421 | + val name: String = "", | |
| 422 | + val accent: String? = null, | |
| 423 | + /** `ready`, `checking`, `missing`, `disabled` or `failed`. */ | |
| 424 | + val status: String = "ready", | |
| 425 | + val account: String? = null, | |
| 426 | + val plan: String? = null, | |
| 427 | + val defaultModel: String? = null, | |
| 428 | + val models: List<RemoteModel> = emptyList(), | |
| 429 | + val commands: List<RemoteCommand> = emptyList(), | |
| 430 | + ) | |
| 431 | + | |
| 432 | + @Serializable | |
| 433 | + data class RemoteDiffLine( | |
| 434 | + /** `hunk`, `context`, `added` or `removed`. */ | |
| 435 | + val kind: String = "context", | |
| 436 | + val text: String = "", | |
| 437 | + val oldLine: Int? = null, | |
| 438 | + val newLine: Int? = null, | |
| 439 | + ) | |
| 440 | + | |
| 441 | + @Serializable | |
| 442 | + data class RemoteFileDiff(val path: String = "", val binary: Boolean = false, val lines: List<RemoteDiffLine> = emptyList()) | |
| 443 | + | |
| 444 | + @Serializable | |
| 445 | + sealed class ServerMessage { | |
| 446 | + @Serializable @SerialName("welcome") | |
| 447 | + data class Welcome(val host: String = "", val version: String = "", val protocol: Int = 0) : ServerMessage() | |
| 448 | + | |
| 449 | + @Serializable @SerialName("denied") | |
| 450 | + data class Denied(val reason: String = "") : ServerMessage() | |
| 451 | + | |
| 452 | + @Serializable @SerialName("state") | |
| 453 | + data class State(val projects: List<ProjectInfo> = emptyList(), val threads: List<ThreadSummary> = emptyList()) : ServerMessage() | |
| 454 | + | |
| 455 | + /** A subscribed thread changed: its transcript from item `from` on; `len` items in all. */ | |
| 456 | + @Serializable @SerialName("thread") | |
| 457 | + data class Thread( | |
| 458 | + val summary: ThreadSummary = ThreadSummary(), | |
| 459 | + val detail: ThreadDetail = ThreadDetail(), | |
| 460 | + val from: Int = 0, | |
| 461 | + val len: Int = 0, | |
| 462 | + val items: List<SavedItem> = emptyList(), | |
| 463 | + ) : ServerMessage() | |
| 464 | + | |
| 465 | + @Serializable @SerialName("created") | |
| 466 | + data class Created(val reference: Long = 0, val thread: String = "") : ServerMessage() | |
| 467 | + | |
| 468 | + @Serializable @SerialName("error") | |
| 469 | + data class Error(val message: String = "") : ServerMessage() | |
| 470 | + | |
| 471 | + /** The host's environment settings and project overrides, raw. */ | |
| 472 | + @Serializable @SerialName("settings") | |
| 473 | + data class Settings(val values: JsonObject = JsonObject(emptyMap())) : ServerMessage() | |
| 474 | + | |
| 475 | + @Serializable @SerialName("folders") | |
| 476 | + data class Folders( | |
| 477 | + val path: String = "", | |
| 478 | + val folders: List<Folder> = emptyList(), | |
| 479 | + val truncated: Boolean = false, | |
| 480 | + val error: String? = null, | |
| 481 | + ) : ServerMessage() | |
| 482 | + | |
| 483 | + @Serializable @SerialName("project_added") | |
| 484 | + data class ProjectAdded(val path: String = "") : ServerMessage() | |
| 485 | + | |
| 486 | + @Serializable @SerialName("terminal_output") | |
| 487 | + data class TerminalOutput(val terminal: Long = 0, val data: String = "") : ServerMessage() | |
| 488 | + | |
| 489 | + @Serializable @SerialName("terminal_exited") | |
| 490 | + data class TerminalExited(val terminal: Long = 0, val error: String? = null) : ServerMessage() | |
| 491 | + | |
| 492 | + @Serializable @SerialName("device_reply") | |
| 493 | + data class DeviceReply(val request: Long = 0, val ok: JsonElement? = null, val error: String? = null) : ServerMessage() | |
| 494 | + | |
| 495 | + @Serializable @SerialName("device_state") | |
| 496 | + data class DeviceState(val state: JsonObject = JsonObject(emptyMap())) : ServerMessage() | |
| 497 | + | |
| 498 | + @Serializable @SerialName("device_opened") | |
| 499 | + data class DeviceOpened(val thread: String = "", val device: JsonObject = JsonObject(emptyMap())) : ServerMessage() | |
| 500 | + | |
| 501 | + @Serializable @SerialName("device_closed") | |
| 502 | + data class DeviceClosed(val thread: String = "", val device: String? = null) : ServerMessage() | |
| 503 | + | |
| 504 | + @Serializable @SerialName("catalog") | |
| 505 | + data class Catalog(val instances: List<RemoteInstance> = emptyList(), val favorites: List<String> = emptyList()) : ServerMessage() | |
| 506 | + | |
| 507 | + @Serializable @SerialName("diff_reply") | |
| 508 | + data class DiffReply(val request: Long = 0, val files: List<RemoteFileDiff> = emptyList(), val error: String? = null) : ServerMessage() | |
| 509 | + } | |
| 510 | + | |
| 511 | + /** A message from the host, or `null` for one this app doesn't know. */ | |
| 512 | + fun parseServerMessage(text: String): ServerMessage? = try { | |
| 513 | + WireJson.decodeFromString(ServerMessage.serializer(), text) | |
| 514 | + } catch (_: Exception) { | |
| 515 | + null | |
| 516 | + } | |
| 517 | + | |
| 518 | + fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessage.serializer(), message) | |
| 519 | + | |
| 520 | + /** A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`. */ | |
| 521 | + fun parsePairing(code: String): Pair<String, String?> { | |
| 522 | + val trimmed = code.trim().removePrefix("ws://").removePrefix("vectrum://") | |
| 523 | + val slash = trimmed.indexOf('/') | |
| 524 | + if (slash < 0) return trimmed to null | |
| 525 | + val key = trimmed.substring(slash + 1).trim() | |
| 526 | + return trimmed.substring(0, slash) to key.ifEmpty { null } | |
| 527 | + } | |
| 528 | + | |
| 529 | + /** `host` or `host:port`, with the default port filled in. */ | |
| 530 | + fun withPort(address: String): String { | |
| 531 | + val trimmed = address.trim() | |
| 532 | + val colon = trimmed.lastIndexOf(':') | |
| 533 | + val hasPort = colon > 0 && trimmed.substring(colon + 1).toIntOrNull() != null && | |
| 534 | + (!trimmed.substring(0, colon).contains(':') || trimmed.substring(0, colon).endsWith("]")) | |
| 535 | + return if (hasPort) trimmed else "$trimmed:$DEFAULT_PORT" | |
| 536 | + } | |
| 537 | + | |
| 538 | + /** A tool input's string field, if it has one. */ | |
| 539 | + fun JsonElement.str(key: String): String? = | |
| 540 | + (this as? JsonObject)?.get(key)?.let { runCatching { it.jsonPrimitive.content }.getOrNull() } | |
| 541 | + | |
| 542 | + fun JsonElement.obj(): JsonObject? = runCatching { jsonObject }.getOrNull() | |
| @@ -0,0 +1,94 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** Keys that send escape sequences rather than text. */ | |
| 4 | + enum class TermKey { | |
| 5 | + Enter, Tab, Escape, Backspace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, | |
| 6 | + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, | |
| 7 | + } | |
| 8 | + | |
| 9 | + /** | |
| 10 | + * What a key sends to the shell, as xterm encodes it: the desktop's | |
| 11 | + * `key_bytes` (src/terminal.rs). Cursor keys follow DECCKM ([appCursor]). | |
| 12 | + */ | |
| 13 | + fun keyBytes(key: TermKey, appCursor: Boolean, ctrl: Boolean = false, alt: Boolean = false, shift: Boolean = false): ByteArray { | |
| 14 | + // xterm's modifier parameter: 1 + Shift + 2·Alt + 4·Ctrl. | |
| 15 | + val param = 1 + (if (shift) 1 else 0) + (if (alt) 2 else 0) + (if (ctrl) 4 else 0) | |
| 16 | + fun escaped(text: String) = ((if (alt) "\u001b" else "") + text).toByteArray() | |
| 17 | + fun cursor(letter: Char) = when { | |
| 18 | + param == 1 && appCursor -> "\u001bO$letter" | |
| 19 | + param == 1 -> "\u001b[$letter" | |
| 20 | + else -> "\u001b[1;$param$letter" | |
| 21 | + }.toByteArray() | |
| 22 | + fun tilde(code: Int) = (if (param == 1) "\u001b[$code~" else "\u001b[$code;$param~").toByteArray() | |
| 23 | + return when (key) { | |
| 24 | + TermKey.Enter -> escaped("\r") | |
| 25 | + TermKey.Tab -> if (shift) "\u001b[Z".toByteArray() else escaped("\t") | |
| 26 | + TermKey.Escape -> escaped("\u001b") | |
| 27 | + TermKey.Backspace -> if (ctrl) escaped("\u0008") else escaped("\u007f") | |
| 28 | + TermKey.Up -> cursor('A') | |
| 29 | + TermKey.Down -> cursor('B') | |
| 30 | + TermKey.Right -> cursor('C') | |
| 31 | + TermKey.Left -> cursor('D') | |
| 32 | + TermKey.Home -> cursor('H') | |
| 33 | + TermKey.End -> cursor('F') | |
| 34 | + TermKey.Insert -> tilde(2) | |
| 35 | + TermKey.Delete -> tilde(3) | |
| 36 | + TermKey.PageUp -> tilde(5) | |
| 37 | + TermKey.PageDown -> tilde(6) | |
| 38 | + TermKey.F1, TermKey.F2, TermKey.F3, TermKey.F4 -> { | |
| 39 | + val letter = 'P' + (key.ordinal - TermKey.F1.ordinal) | |
| 40 | + (if (param == 1) "\u001bO$letter" else "\u001b[1;$param$letter").toByteArray() | |
| 41 | + } | |
| 42 | + TermKey.F5 -> tilde(15) | |
| 43 | + TermKey.F6 -> tilde(17) | |
| 44 | + TermKey.F7 -> tilde(18) | |
| 45 | + TermKey.F8 -> tilde(19) | |
| 46 | + TermKey.F9 -> tilde(20) | |
| 47 | + TermKey.F10 -> tilde(21) | |
| 48 | + TermKey.F11 -> tilde(23) | |
| 49 | + TermKey.F12 -> tilde(24) | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * Typed text, with Ctrl and Alt applied to it as the desktop applies them | |
| 55 | + * to a key: Ctrl turns a letter into its control code, Alt puts ESC first. | |
| 56 | + */ | |
| 57 | + fun textBytes(text: String, ctrl: Boolean = false, alt: Boolean = false): ByteArray { | |
| 58 | + if (text.isEmpty()) return ByteArray(0) | |
| 59 | + var body = text.replace("\r\n", "\r").replace('\n', '\r') | |
| 60 | + if (ctrl) { | |
| 61 | + val first = body.codePointAt(0) | |
| 62 | + val control = controlCode(first) | |
| 63 | + if (control != null) body = String(Character.toChars(control)) + body.substring(Character.charCount(first)) | |
| 64 | + } | |
| 65 | + return ((if (alt) "\u001b" else "") + body).toByteArray() | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** The control code Ctrl and a character make, as the desktop's table has them. */ | |
| 69 | + fun controlCode(c: Int): Int? { | |
| 70 | + val lower = if (c in 'A'.code..'Z'.code) c + 32 else c | |
| 71 | + return when (lower) { | |
| 72 | + in 'a'.code..'z'.code -> lower - 'a'.code + 1 | |
| 73 | + ' '.code, '@'.code, '2'.code -> 0 | |
| 74 | + '['.code, '3'.code -> 0x1b | |
| 75 | + '\\'.code, '4'.code -> 0x1c | |
| 76 | + ']'.code, '5'.code -> 0x1d | |
| 77 | + '^'.code, '6'.code -> 0x1e | |
| 78 | + '_'.code, '-'.code, '/'.code, '7'.code -> 0x1f | |
| 79 | + '?'.code, '8'.code -> 0x7f | |
| 80 | + else -> null | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * What a paste sends: line breaks as Enter, and inside bracketed paste | |
| 86 | + * markers when the program asked for them (with any markers in the text | |
| 87 | + * taken out, so a paste can't end itself early). | |
| 88 | + */ | |
| 89 | + fun pasteBytes(text: String, bracketed: Boolean): ByteArray = | |
| 90 | + if (bracketed) { | |
| 91 | + ("\u001b[200~" + text.replace("\u001b", "") + "\u001b[201~").toByteArray() | |
| 92 | + } else { | |
| 93 | + text.replace("\r\n", "\r").replace('\n', '\r').toByteArray() | |
| 94 | + } | |
| @@ -0,0 +1,105 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** | |
| 4 | + * A cell's color: [DEFAULT], one of the 256 indexed colors, or 24-bit RGB, | |
| 5 | + * packed in an `Int` so a line's colors are plain arrays. | |
| 6 | + */ | |
| 7 | + object TermColor { | |
| 8 | + const val DEFAULT = 0 | |
| 9 | + private const val INDEXED = 1 shl 24 | |
| 10 | + private const val RGB = 2 shl 24 | |
| 11 | + | |
| 12 | + fun indexed(index: Int) = INDEXED or (index and 0xff) | |
| 13 | + | |
| 14 | + fun rgb(r: Int, g: Int, b: Int) = RGB or ((r and 0xff) shl 16) or ((g and 0xff) shl 8) or (b and 0xff) | |
| 15 | + | |
| 16 | + fun isIndexed(color: Int) = color ushr 24 == 1 | |
| 17 | + | |
| 18 | + fun isRgb(color: Int) = color ushr 24 == 2 | |
| 19 | + | |
| 20 | + /** The index of an indexed color, or the `0xrrggbb` of an RGB one. */ | |
| 21 | + fun value(color: Int) = color and 0xffffff | |
| 22 | + } | |
| 23 | + | |
| 24 | + /** A cell's attributes, as bits. */ | |
| 25 | + object Attr { | |
| 26 | + const val BOLD = 1 | |
| 27 | + const val DIM = 2 | |
| 28 | + const val ITALIC = 4 | |
| 29 | + const val UNDERLINE = 8 | |
| 30 | + const val INVERSE = 16 | |
| 31 | + const val HIDDEN = 32 | |
| 32 | + const val STRIKE = 64 | |
| 33 | + | |
| 34 | + /** The first half of a double-width character. */ | |
| 35 | + const val WIDE = 128 | |
| 36 | + | |
| 37 | + /** The second half of a double-width character: nothing to draw. */ | |
| 38 | + const val SPACER = 256 | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** One row of cells: code points (0 for blank), colors and attributes. */ | |
| 42 | + class Line(columns: Int) { | |
| 43 | + var chars = IntArray(columns) | |
| 44 | + private set | |
| 45 | + var fg = IntArray(columns) | |
| 46 | + private set | |
| 47 | + var bg = IntArray(columns) | |
| 48 | + private set | |
| 49 | + var attrs = IntArray(columns) | |
| 50 | + private set | |
| 51 | + | |
| 52 | + /** The text goes on in the next line: it wrapped here rather than broke. */ | |
| 53 | + var wrapped = false | |
| 54 | + | |
| 55 | + val columns: Int get() = chars.size | |
| 56 | + | |
| 57 | + fun resize(columns: Int) { | |
| 58 | + val old = chars.size | |
| 59 | + if (columns == old) return | |
| 60 | + chars = chars.copyOf(columns) | |
| 61 | + fg = fg.copyOf(columns) | |
| 62 | + bg = bg.copyOf(columns) | |
| 63 | + attrs = attrs.copyOf(columns) | |
| 64 | + // A wide character cut in half by the new edge goes. | |
| 65 | + if (columns > 0 && attrs[columns - 1] and Attr.WIDE != 0) { | |
| 66 | + chars[columns - 1] = 0 | |
| 67 | + attrs[columns - 1] = 0 | |
| 68 | + } | |
| 69 | + if (columns < old) wrapped = false | |
| 70 | + } | |
| 71 | + | |
| 72 | + /** Blanks `from until to` in background [background]. */ | |
| 73 | + fun clear(from: Int, to: Int, background: Int) { | |
| 74 | + val end = to.coerceAtMost(chars.size) | |
| 75 | + for (i in from.coerceAtLeast(0) until end) { | |
| 76 | + chars[i] = 0 | |
| 77 | + fg[i] = TermColor.DEFAULT | |
| 78 | + bg[i] = background | |
| 79 | + attrs[i] = 0 | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + fun copyCell(from: Int, to: Int) { | |
| 84 | + chars[to] = chars[from] | |
| 85 | + fg[to] = fg[from] | |
| 86 | + bg[to] = bg[from] | |
| 87 | + attrs[to] = attrs[from] | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** The row's text, blanks as spaces; trailing blanks trimmed when asked. */ | |
| 91 | + fun text(trim: Boolean = true): String { | |
| 92 | + val builder = StringBuilder(chars.size) | |
| 93 | + for (i in chars.indices) { | |
| 94 | + if (attrs[i] and Attr.SPACER != 0) continue | |
| 95 | + val c = chars[i] | |
| 96 | + if (c == 0) builder.append(' ') else builder.appendCodePoint(c) | |
| 97 | + } | |
| 98 | + return if (trim) builder.trimEnd().toString() else builder.toString() | |
| 99 | + } | |
| 100 | + | |
| 101 | + fun isBlank(): Boolean { | |
| 102 | + for (i in chars.indices) if (chars[i] != 0 || bg[i] != TermColor.DEFAULT) return false | |
| 103 | + return true | |
| 104 | + } | |
| 105 | + } | |
| @@ -0,0 +1,975 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** How the cursor is drawn, as `DECSCUSR` sets it. */ | |
| 4 | + enum class CursorShape { Block, Underline, Beam } | |
| 5 | + | |
| 6 | + /** | |
| 7 | + * A compact VT/xterm emulator: the screen a shell draws on. It parses what | |
| 8 | + * the shell prints (UTF-8, C0 controls, ESC, CSI, OSC and DCS sequences) | |
| 9 | + * into a grid of cells with an alternate screen and a scrollback, and | |
| 10 | + * answers the queries a shell asks (cursor position, device attributes, | |
| 11 | + * colors) through [respond]. The desktop does this with | |
| 12 | + * `alacritty_terminal`; this keeps to what shells and full-screen programs | |
| 13 | + * use. | |
| 14 | + * | |
| 15 | + * Not thread-safe: feed, resize and read it on one thread. | |
| 16 | + */ | |
| 17 | + class Terminal( | |
| 18 | + columns: Int, | |
| 19 | + rows: Int, | |
| 20 | + private val scrollbackLimit: Int = SCROLLBACK, | |
| 21 | + /** Bytes the terminal sends back to the shell, as answers to queries. */ | |
| 22 | + private val respond: (ByteArray) -> Unit = {}, | |
| 23 | + /** The `0xrrggbb` of an indexed color (256 is the foreground, 257 the background, 258 the cursor), for color queries. */ | |
| 24 | + private val colorOf: (Int) -> Int? = { null }, | |
| 25 | + ) { | |
| 26 | + var columns = columns.coerceAtLeast(1) | |
| 27 | + private set | |
| 28 | + var rows = rows.coerceAtLeast(1) | |
| 29 | + private set | |
| 30 | + | |
| 31 | + private var main = ArrayList<Line>() | |
| 32 | + private var alternate = ArrayList<Line>() | |
| 33 | + | |
| 34 | + /** The rows on screen, top to bottom. */ | |
| 35 | + private var screen = main | |
| 36 | + | |
| 37 | + /** Lines scrolled off the top of the main screen, oldest first. */ | |
| 38 | + private val history = ArrayDeque<Line>() | |
| 39 | + | |
| 40 | + /** The alternate screen shows: full-screen programs draw there. */ | |
| 41 | + var altScreen = false | |
| 42 | + private set | |
| 43 | + | |
| 44 | + // ---- cursor and pen ---- | |
| 45 | + | |
| 46 | + var cursorRow = 0 | |
| 47 | + private set | |
| 48 | + var cursorColumn = 0 | |
| 49 | + private set | |
| 50 | + | |
| 51 | + /** A character went into the last column; the next one wraps first. */ | |
| 52 | + private var pendingWrap = false | |
| 53 | + private var fg = TermColor.DEFAULT | |
| 54 | + private var bg = TermColor.DEFAULT | |
| 55 | + private var attrs = 0 | |
| 56 | + private var lastPrinted = ' '.code | |
| 57 | + | |
| 58 | + private class Saved( | |
| 59 | + val row: Int, | |
| 60 | + val column: Int, | |
| 61 | + val fg: Int, | |
| 62 | + val bg: Int, | |
| 63 | + val attrs: Int, | |
| 64 | + val origin: Boolean, | |
| 65 | + val g0: Boolean, | |
| 66 | + val g1: Boolean, | |
| 67 | + val shifted: Boolean, | |
| 68 | + ) | |
| 69 | + | |
| 70 | + private var savedMain: Saved? = null | |
| 71 | + private var savedAlternate: Saved? = null | |
| 72 | + | |
| 73 | + // ---- modes ---- | |
| 74 | + | |
| 75 | + /** DECCKM: cursor keys send `ESC O A` rather than `ESC [ A`. */ | |
| 76 | + var applicationCursor = false | |
| 77 | + private set | |
| 78 | + | |
| 79 | + /** The program wants pastes between `ESC [200~` and `ESC [201~`. */ | |
| 80 | + var bracketedPaste = false | |
| 81 | + private set | |
| 82 | + var cursorVisible = true | |
| 83 | + private set | |
| 84 | + var cursorShape = CursorShape.Block | |
| 85 | + private set | |
| 86 | + | |
| 87 | + /** Mouse reporting the program asked for: 0 none, else 1000, 1002 or 1003. */ | |
| 88 | + var mouseTracking = 0 | |
| 89 | + private set | |
| 90 | + | |
| 91 | + /** Mouse reports in SGR form (`?1006`). */ | |
| 92 | + var sgrMouse = false | |
| 93 | + private set | |
| 94 | + | |
| 95 | + /** The wheel sends arrow keys on the alternate screen (`?1007`, on by default). */ | |
| 96 | + var alternateScroll = true | |
| 97 | + private set | |
| 98 | + private var autoWrap = true | |
| 99 | + private var originMode = false | |
| 100 | + private var insertMode = false | |
| 101 | + private var newLineMode = false | |
| 102 | + | |
| 103 | + private var scrollTop = 0 | |
| 104 | + private var scrollBottom = this.rows - 1 | |
| 105 | + private var tabStops = BooleanArray(this.columns) | |
| 106 | + | |
| 107 | + /** G0 and G1 are DEC line drawing rather than ASCII, and which one is shifted in. */ | |
| 108 | + private var g0Graphics = false | |
| 109 | + private var g1Graphics = false | |
| 110 | + private var shiftedOut = false | |
| 111 | + | |
| 112 | + /** How many lines up the view is scrolled into the scrollback; 0 follows the shell. */ | |
| 113 | + var displayOffset = 0 | |
| 114 | + private set | |
| 115 | + | |
| 116 | + // ---- parser ---- | |
| 117 | + | |
| 118 | + private enum class State { Ground, Escape, EscapeIntermediate, Csi, Osc, Ignore, IgnoreEscape, Charset } | |
| 119 | + | |
| 120 | + private val decoder = Utf8Decoder() | |
| 121 | + private var state = State.Ground | |
| 122 | + private val params = IntArray(MAX_PARAMS) | |
| 123 | + | |
| 124 | + /** Whether a parameter is a colon subparameter of the one before it. */ | |
| 125 | + private val colon = BooleanArray(MAX_PARAMS) | |
| 126 | + private var paramCount = 0 | |
| 127 | + private var paramStarted = false | |
| 128 | + private var privateMarker = 0 | |
| 129 | + private var intermediate = 0 | |
| 130 | + private var charsetTarget = 0 | |
| 131 | + private val osc = StringBuilder() | |
| 132 | + private var oscEscape = false | |
| 133 | + | |
| 134 | + init { | |
| 135 | + repeat(this.rows) { | |
| 136 | + main.add(Line(this.columns)) | |
| 137 | + alternate.add(Line(this.columns)) | |
| 138 | + } | |
| 139 | + resetTabs() | |
| 140 | + } | |
| 141 | + | |
| 142 | + val historySize: Int get() = history.size | |
| 143 | + | |
| 144 | + // ---- input ---- | |
| 145 | + | |
| 146 | + fun feed(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size) { | |
| 147 | + decoder.decode(bytes, offset, length) { process(it) } | |
| 148 | + } | |
| 149 | + | |
| 150 | + fun feed(text: String) = feed(text.toByteArray()) | |
| 151 | + | |
| 152 | + private fun process(c: Int) { | |
| 153 | + // C0 controls act in the middle of most sequences, as in a VT. | |
| 154 | + if (c < 0x20 && state != State.Osc && state != State.Ignore && state != State.IgnoreEscape) { | |
| 155 | + when (c) { | |
| 156 | + 0x1b -> { | |
| 157 | + state = State.Escape | |
| 158 | + intermediate = 0 | |
| 159 | + } | |
| 160 | + 0x18, 0x1a -> state = State.Ground | |
| 161 | + else -> control(c) | |
| 162 | + } | |
| 163 | + return | |
| 164 | + } | |
| 165 | + when (state) { | |
| 166 | + State.Ground -> print(c) | |
| 167 | + State.Escape -> escape(c) | |
| 168 | + State.EscapeIntermediate -> escapeIntermediate(c) | |
| 169 | + State.Charset -> { | |
| 170 | + val graphics = c == '0'.code | |
| 171 | + if (charsetTarget == 0) g0Graphics = graphics else if (charsetTarget == 1) g1Graphics = graphics | |
| 172 | + state = State.Ground | |
| 173 | + } | |
| 174 | + State.Csi -> csiChar(c) | |
| 175 | + State.Osc -> oscChar(c) | |
| 176 | + State.Ignore -> if (c == 0x1b) state = State.IgnoreEscape else if (c == 0x07) state = State.Ground | |
| 177 | + State.IgnoreEscape -> state = if (c == '\\'.code) State.Ground else State.Ignore | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + private fun control(c: Int) { | |
| 182 | + when (c) { | |
| 183 | + 0x07 -> {} | |
| 184 | + 0x08 -> { | |
| 185 | + pendingWrap = false | |
| 186 | + if (cursorColumn > 0) cursorColumn-- | |
| 187 | + } | |
| 188 | + 0x09 -> tab(1) | |
| 189 | + 0x0a, 0x0b, 0x0c -> { | |
| 190 | + lineFeed() | |
| 191 | + if (newLineMode) carriageReturn() | |
| 192 | + } | |
| 193 | + 0x0d -> carriageReturn() | |
| 194 | + 0x0e -> shiftedOut = true | |
| 195 | + 0x0f -> shiftedOut = false | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + private fun escape(c: Int) { | |
| 200 | + state = State.Ground | |
| 201 | + when (c) { | |
| 202 | + '['.code -> startCsi() | |
| 203 | + ']'.code -> { | |
| 204 | + osc.setLength(0) | |
| 205 | + oscEscape = false | |
| 206 | + state = State.Osc | |
| 207 | + } | |
| 208 | + 'P'.code, 'X'.code, '^'.code, '_'.code -> state = State.Ignore | |
| 209 | + '('.code, ')'.code, '*'.code, '+'.code -> { | |
| 210 | + charsetTarget = c - '('.code | |
| 211 | + state = State.Charset | |
| 212 | + } | |
| 213 | + '7'.code -> saveCursor() | |
| 214 | + '8'.code -> restoreCursor() | |
| 215 | + 'D'.code -> lineFeed() | |
| 216 | + 'E'.code -> { | |
| 217 | + lineFeed() | |
| 218 | + carriageReturn() | |
| 219 | + } | |
| 220 | + 'M'.code -> reverseIndex() | |
| 221 | + 'H'.code -> if (cursorColumn < columns) tabStops[cursorColumn] = true | |
| 222 | + 'c'.code -> reset() | |
| 223 | + in 0x20..0x2f -> { | |
| 224 | + intermediate = c | |
| 225 | + state = State.EscapeIntermediate | |
| 226 | + } | |
| 227 | + // `=`, `>` (keypad modes), `\` (a lone string terminator) and the rest: nothing to do. | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + private fun escapeIntermediate(c: Int) { | |
| 232 | + if (c in 0x20..0x2f) return | |
| 233 | + state = State.Ground | |
| 234 | + if (intermediate == '#'.code && c == '8'.code) { | |
| 235 | + // DECALN: the screen full of Es. | |
| 236 | + for (line in screen) { | |
| 237 | + line.clear(0, columns, TermColor.DEFAULT) | |
| 238 | + for (i in 0 until columns) line.chars[i] = 'E'.code | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + | |
| 243 | + // ---- printing ---- | |
| 244 | + | |
| 245 | + private fun print(c0: Int) { | |
| 246 | + var c = c0 | |
| 247 | + val graphics = if (shiftedOut) g1Graphics else g0Graphics | |
| 248 | + if (graphics && c in 0x5f..0x7e) c = DEC_GRAPHICS[c - 0x5f].code | |
| 249 | + val width = charWidth(c) | |
| 250 | + if (width == 0) return | |
| 251 | + if (pendingWrap) { | |
| 252 | + if (autoWrap) { | |
| 253 | + screen[cursorRow].wrapped = true | |
| 254 | + lineFeed() | |
| 255 | + cursorColumn = 0 | |
| 256 | + } | |
| 257 | + pendingWrap = false | |
| 258 | + } | |
| 259 | + if (width == 2 && cursorColumn == columns - 1) { | |
| 260 | + if (columns < 2) return | |
| 261 | + if (!autoWrap) return | |
| 262 | + // It doesn't fit at the end of the line; it goes on the next. | |
| 263 | + val line = screen[cursorRow] | |
| 264 | + clearWide(line, cursorColumn) | |
| 265 | + line.clear(cursorColumn, columns, bg) | |
| 266 | + line.wrapped = true | |
| 267 | + lineFeed() | |
| 268 | + cursorColumn = 0 | |
| 269 | + } | |
| 270 | + val line = screen[cursorRow] | |
| 271 | + if (insertMode) insertBlanks(line, cursorColumn, width) | |
| 272 | + clearWide(line, cursorColumn) | |
| 273 | + if (width == 2) clearWide(line, cursorColumn + 1) | |
| 274 | + line.chars[cursorColumn] = c | |
| 275 | + line.fg[cursorColumn] = fg | |
| 276 | + line.bg[cursorColumn] = bg | |
| 277 | + line.attrs[cursorColumn] = attrs or if (width == 2) Attr.WIDE else 0 | |
| 278 | + if (width == 2) { | |
| 279 | + line.chars[cursorColumn + 1] = 0 | |
| 280 | + line.fg[cursorColumn + 1] = fg | |
| 281 | + line.bg[cursorColumn + 1] = bg | |
| 282 | + line.attrs[cursorColumn + 1] = attrs or Attr.SPACER | |
| 283 | + } | |
| 284 | + lastPrinted = c | |
| 285 | + if (cursorColumn + width >= columns) { | |
| 286 | + cursorColumn = columns - 1 | |
| 287 | + pendingWrap = true | |
| 288 | + } else { | |
| 289 | + cursorColumn += width | |
| 290 | + } | |
| 291 | + } | |
| 292 | + | |
| 293 | + /** Overwriting either half of a wide character blanks the other half. */ | |
| 294 | + private fun clearWide(line: Line, column: Int) { | |
| 295 | + if (column !in 0 until line.columns) return | |
| 296 | + val a = line.attrs[column] | |
| 297 | + if (a and Attr.SPACER != 0 && column > 0) { | |
| 298 | + line.chars[column - 1] = 0 | |
| 299 | + line.attrs[column - 1] = line.attrs[column - 1] and Attr.WIDE.inv() | |
| 300 | + } | |
| 301 | + if (a and Attr.WIDE != 0 && column + 1 < line.columns) { | |
| 302 | + line.chars[column + 1] = 0 | |
| 303 | + line.attrs[column + 1] = line.attrs[column + 1] and Attr.SPACER.inv() | |
| 304 | + } | |
| 305 | + line.attrs[column] = a and (Attr.WIDE or Attr.SPACER).inv() | |
| 306 | + } | |
| 307 | + | |
| 308 | + private fun carriageReturn() { | |
| 309 | + cursorColumn = 0 | |
| 310 | + pendingWrap = false | |
| 311 | + } | |
| 312 | + | |
| 313 | + private fun lineFeed() { | |
| 314 | + pendingWrap = false | |
| 315 | + if (cursorRow == scrollBottom) { | |
| 316 | + scrollUp(1) | |
| 317 | + } else if (cursorRow < rows - 1) { | |
| 318 | + cursorRow++ | |
| 319 | + } | |
| 320 | + } | |
| 321 | + | |
| 322 | + private fun reverseIndex() { | |
| 323 | + pendingWrap = false | |
| 324 | + if (cursorRow == scrollTop) scrollDown(1) else if (cursorRow > 0) cursorRow-- | |
| 325 | + } | |
| 326 | + | |
| 327 | + private fun tab(count: Int) { | |
| 328 | + pendingWrap = false | |
| 329 | + repeat(count) { | |
| 330 | + var column = cursorColumn + 1 | |
| 331 | + while (column < columns - 1 && !tabStops[column]) column++ | |
| 332 | + cursorColumn = column.coerceAtMost(columns - 1) | |
| 333 | + } | |
| 334 | + } | |
| 335 | + | |
| 336 | + private fun backTab(count: Int) { | |
| 337 | + pendingWrap = false | |
| 338 | + repeat(count) { | |
| 339 | + var column = cursorColumn - 1 | |
| 340 | + while (column > 0 && !tabStops[column]) column-- | |
| 341 | + cursorColumn = column.coerceAtLeast(0) | |
| 342 | + } | |
| 343 | + } | |
| 344 | + | |
| 345 | + private fun blankLine() = Line(columns).also { if (bg != TermColor.DEFAULT) it.clear(0, columns, bg) } | |
| 346 | + | |
| 347 | + /** Scrolls the region up: its top lines go, into the scrollback when it's the main screen's top. */ | |
| 348 | + private fun scrollUp(count: Int, top: Int = scrollTop, bottom: Int = scrollBottom) { | |
| 349 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 350 | + repeat(n) { | |
| 351 | + val gone = screen.removeAt(top) | |
| 352 | + if (top == 0 && !altScreen) { | |
| 353 | + history.addLast(gone) | |
| 354 | + if (history.size > scrollbackLimit) history.removeFirst() else if (displayOffset > 0) displayOffset++ | |
| 355 | + displayOffset = displayOffset.coerceAtMost(history.size) | |
| 356 | + } | |
| 357 | + screen.add(bottom, blankLine()) | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + private fun scrollDown(count: Int, top: Int = scrollTop, bottom: Int = scrollBottom) { | |
| 362 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 363 | + repeat(n) { | |
| 364 | + screen.removeAt(bottom) | |
| 365 | + screen.add(top, blankLine()) | |
| 366 | + } | |
| 367 | + } | |
| 368 | + | |
| 369 | + private fun insertBlanks(line: Line, at: Int, count: Int) { | |
| 370 | + val n = count.coerceAtMost(columns - at) | |
| 371 | + clearWide(line, at) | |
| 372 | + for (i in columns - 1 downTo at + n) line.copyCell(i - n, i) | |
| 373 | + line.clear(at, at + n, bg) | |
| 374 | + if (line.attrs[columns - 1] and Attr.WIDE != 0) line.clear(columns - 1, columns, bg) | |
| 375 | + } | |
| 376 | + | |
| 377 | + // ---- CSI ---- | |
| 378 | + | |
| 379 | + private fun startCsi() { | |
| 380 | + state = State.Csi | |
| 381 | + paramCount = 0 | |
| 382 | + paramStarted = false | |
| 383 | + privateMarker = 0 | |
| 384 | + intermediate = 0 | |
| 385 | + params.fill(-1) | |
| 386 | + colon.fill(false) | |
| 387 | + } | |
| 388 | + | |
| 389 | + private fun csiChar(c: Int) { | |
| 390 | + when (c) { | |
| 391 | + in '0'.code..'9'.code -> { | |
| 392 | + if (paramCount == 0) paramCount = 1 | |
| 393 | + val ix = paramCount - 1 | |
| 394 | + if (ix < MAX_PARAMS) { | |
| 395 | + val current = params[ix].coerceAtLeast(0) | |
| 396 | + params[ix] = (current * 10 + (c - '0'.code)).coerceAtMost(65535) | |
| 397 | + } | |
| 398 | + paramStarted = true | |
| 399 | + } | |
| 400 | + ';'.code, ':'.code -> { | |
| 401 | + if (paramCount == 0) paramCount = 1 | |
| 402 | + if (paramCount < MAX_PARAMS) { | |
| 403 | + colon[paramCount] = c == ':'.code | |
| 404 | + paramCount++ | |
| 405 | + } | |
| 406 | + } | |
| 407 | + '?'.code, '>'.code, '='.code, '<'.code -> if (paramCount == 0) privateMarker = c | |
| 408 | + in 0x20..0x2f -> intermediate = c | |
| 409 | + in 0x40..0x7e -> { | |
| 410 | + state = State.Ground | |
| 411 | + csi(c) | |
| 412 | + } | |
| 413 | + else -> state = State.Ground | |
| 414 | + } | |
| 415 | + } | |
| 416 | + | |
| 417 | + private fun param(ix: Int, default: Int): Int { | |
| 418 | + if (ix >= paramCount) return default | |
| 419 | + val value = params[ix] | |
| 420 | + return if (value <= 0) default else value | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** A parameter where 0 is meaningful. */ | |
| 424 | + private fun raw(ix: Int, default: Int): Int = if (ix >= paramCount || params[ix] < 0) default else params[ix] | |
| 425 | + | |
| 426 | + private fun csi(final: Int) { | |
| 427 | + if (privateMarker == '?'.code) { | |
| 428 | + when (final) { | |
| 429 | + 'h'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) privateMode(raw(i, 0), true) | |
| 430 | + 'l'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) privateMode(raw(i, 0), false) | |
| 431 | + 'n'.code -> if (raw(0, 0) == 6) reportCursor(true) | |
| 432 | + } | |
| 433 | + return | |
| 434 | + } | |
| 435 | + if (privateMarker == '>'.code) { | |
| 436 | + if (final == 'c'.code) reply("\u001b[>0;100;0c") | |
| 437 | + return | |
| 438 | + } | |
| 439 | + if (privateMarker != 0) return | |
| 440 | + if (intermediate == ' '.code) { | |
| 441 | + if (final == 'q'.code) { | |
| 442 | + cursorShape = when (raw(0, 0)) { | |
| 443 | + 3, 4 -> CursorShape.Underline | |
| 444 | + 5, 6 -> CursorShape.Beam | |
| 445 | + else -> CursorShape.Block | |
| 446 | + } | |
| 447 | + } | |
| 448 | + return | |
| 449 | + } | |
| 450 | + if (intermediate == '!'.code) { | |
| 451 | + if (final == 'p'.code) softReset() | |
| 452 | + return | |
| 453 | + } | |
| 454 | + if (intermediate != 0) return | |
| 455 | + when (final) { | |
| 456 | + '@'.code -> { | |
| 457 | + pendingWrap = false | |
| 458 | + insertBlanks(screen[cursorRow], cursorColumn, param(0, 1)) | |
| 459 | + } | |
| 460 | + 'A'.code -> moveTo(cursorRow - param(0, 1), cursorColumn, clampToRegion = true) | |
| 461 | + 'B'.code, 'e'.code -> moveTo(cursorRow + param(0, 1), cursorColumn, clampToRegion = true) | |
| 462 | + 'C'.code, 'a'.code -> moveTo(cursorRow, cursorColumn + param(0, 1)) | |
| 463 | + 'D'.code -> moveTo(cursorRow, cursorColumn - param(0, 1)) | |
| 464 | + 'E'.code -> moveTo(cursorRow + param(0, 1), 0, clampToRegion = true) | |
| 465 | + 'F'.code -> moveTo(cursorRow - param(0, 1), 0, clampToRegion = true) | |
| 466 | + 'G'.code, '`'.code -> moveTo(cursorRow, param(0, 1) - 1) | |
| 467 | + 'H'.code, 'f'.code -> { | |
| 468 | + val row = param(0, 1) - 1 | |
| 469 | + val column = param(1, 1) - 1 | |
| 470 | + if (originMode) moveTo(row + scrollTop, column, clampToRegion = true) else moveTo(row, column) | |
| 471 | + } | |
| 472 | + 'd'.code -> { | |
| 473 | + val row = param(0, 1) - 1 | |
| 474 | + if (originMode) moveTo(row + scrollTop, cursorColumn, clampToRegion = true) else moveTo(row, cursorColumn) | |
| 475 | + } | |
| 476 | + 'I'.code -> tab(param(0, 1)) | |
| 477 | + 'Z'.code -> backTab(param(0, 1)) | |
| 478 | + 'J'.code -> eraseDisplay(raw(0, 0)) | |
| 479 | + 'K'.code -> eraseLine(raw(0, 0)) | |
| 480 | + 'L'.code -> if (cursorRow in scrollTop..scrollBottom) { | |
| 481 | + scrollDown(param(0, 1), cursorRow, scrollBottom) | |
| 482 | + carriageReturn() | |
| 483 | + } | |
| 484 | + 'M'.code -> if (cursorRow in scrollTop..scrollBottom) { | |
| 485 | + scrollUpInPlace(param(0, 1), cursorRow, scrollBottom) | |
| 486 | + carriageReturn() | |
| 487 | + } | |
| 488 | + 'P'.code -> deleteChars(param(0, 1)) | |
| 489 | + 'X'.code -> { | |
| 490 | + pendingWrap = false | |
| 491 | + val line = screen[cursorRow] | |
| 492 | + val end = (cursorColumn + param(0, 1)).coerceAtMost(columns) | |
| 493 | + clearWide(line, cursorColumn) | |
| 494 | + if (end < columns) clearWide(line, end - 1) | |
| 495 | + line.clear(cursorColumn, end, bg) | |
| 496 | + } | |
| 497 | + 'S'.code -> scrollUp(param(0, 1)) | |
| 498 | + 'T'.code -> scrollDown(param(0, 1)) | |
| 499 | + 'b'.code -> repeat(param(0, 1).coerceAtMost(65535)) { print(lastPrinted) } | |
| 500 | + 'c'.code -> if (raw(0, 0) == 0) reply("\u001b[?62;22c") | |
| 501 | + 'g'.code -> when (raw(0, 0)) { | |
| 502 | + 0 -> if (cursorColumn < columns) tabStops[cursorColumn] = false | |
| 503 | + 3 -> tabStops.fill(false) | |
| 504 | + } | |
| 505 | + 'h'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) ansiMode(raw(i, 0), true) | |
| 506 | + 'l'.code -> for (i in 0 until paramCount.coerceAtLeast(1)) ansiMode(raw(i, 0), false) | |
| 507 | + 'm'.code -> sgr() | |
| 508 | + 'n'.code -> when (raw(0, 0)) { | |
| 509 | + 5 -> reply("\u001b[0n") | |
| 510 | + 6 -> reportCursor(false) | |
| 511 | + } | |
| 512 | + 'r'.code -> { | |
| 513 | + val top = param(0, 1) - 1 | |
| 514 | + val bottom = param(1, rows) - 1 | |
| 515 | + if (top < bottom && bottom < rows) { | |
| 516 | + scrollTop = top | |
| 517 | + scrollBottom = bottom | |
| 518 | + } else if (paramCount == 0) { | |
| 519 | + scrollTop = 0 | |
| 520 | + scrollBottom = rows - 1 | |
| 521 | + } | |
| 522 | + moveTo(if (originMode) scrollTop else 0, 0) | |
| 523 | + } | |
| 524 | + 's'.code -> saveCursor() | |
| 525 | + 'u'.code -> restoreCursor() | |
| 526 | + 't'.code -> if (raw(0, 0) == 18) reply("\u001b[8;$rows;${columns}t") | |
| 527 | + } | |
| 528 | + } | |
| 529 | + | |
| 530 | + /** DL: lines at the cursor go; blanks come in at the region's bottom. Never into the scrollback. */ | |
| 531 | + private fun scrollUpInPlace(count: Int, top: Int, bottom: Int) { | |
| 532 | + val n = count.coerceAtMost(bottom - top + 1) | |
| 533 | + repeat(n) { | |
| 534 | + screen.removeAt(top) | |
| 535 | + screen.add(bottom, blankLine()) | |
| 536 | + } | |
| 537 | + } | |
| 538 | + | |
| 539 | + private fun deleteChars(count: Int) { | |
| 540 | + pendingWrap = false | |
| 541 | + val line = screen[cursorRow] | |
| 542 | + val n = count.coerceAtMost(columns - cursorColumn) | |
| 543 | + clearWide(line, cursorColumn) | |
| 544 | + if (cursorColumn + n < columns) clearWide(line, cursorColumn + n) | |
| 545 | + for (i in cursorColumn until columns - n) line.copyCell(i + n, i) | |
| 546 | + line.clear(columns - n, columns, bg) | |
| 547 | + } | |
| 548 | + | |
| 549 | + private fun moveTo(row: Int, column: Int, clampToRegion: Boolean = false) { | |
| 550 | + pendingWrap = false | |
| 551 | + val (top, bottom) = if (clampToRegion && cursorRow in scrollTop..scrollBottom) scrollTop to scrollBottom else 0 to rows - 1 | |
| 552 | + cursorRow = row.coerceIn(top, bottom) | |
| 553 | + cursorColumn = column.coerceIn(0, columns - 1) | |
| 554 | + } | |
| 555 | + | |
| 556 | + private fun eraseDisplay(mode: Int) { | |
| 557 | + pendingWrap = false | |
| 558 | + when (mode) { | |
| 559 | + 0 -> { | |
| 560 | + val line = screen[cursorRow] | |
| 561 | + clearWide(line, cursorColumn) | |
| 562 | + line.clear(cursorColumn, columns, bg) | |
| 563 | + line.wrapped = false | |
| 564 | + for (row in cursorRow + 1 until rows) blank(row) | |
| 565 | + } | |
| 566 | + 1 -> { | |
| 567 | + for (row in 0 until cursorRow) blank(row) | |
| 568 | + val line = screen[cursorRow] | |
| 569 | + if (cursorColumn + 1 < columns) clearWide(line, cursorColumn + 1) | |
| 570 | + line.clear(0, cursorColumn + 1, bg) | |
| 571 | + } | |
| 572 | + 2 -> for (row in 0 until rows) blank(row) | |
| 573 | + 3 -> { | |
| 574 | + history.clear() | |
| 575 | + displayOffset = 0 | |
| 576 | + } | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + private fun blank(row: Int) { | |
| 581 | + screen[row].clear(0, columns, bg) | |
| 582 | + screen[row].wrapped = false | |
| 583 | + } | |
| 584 | + | |
| 585 | + private fun eraseLine(mode: Int) { | |
| 586 | + pendingWrap = false | |
| 587 | + val line = screen[cursorRow] | |
| 588 | + when (mode) { | |
| 589 | + 0 -> { | |
| 590 | + clearWide(line, cursorColumn) | |
| 591 | + line.clear(cursorColumn, columns, bg) | |
| 592 | + line.wrapped = false | |
| 593 | + } | |
| 594 | + 1 -> { | |
| 595 | + if (cursorColumn + 1 < columns) clearWide(line, cursorColumn + 1) | |
| 596 | + line.clear(0, cursorColumn + 1, bg) | |
| 597 | + } | |
| 598 | + 2 -> { | |
| 599 | + line.clear(0, columns, bg) | |
| 600 | + line.wrapped = false | |
| 601 | + } | |
| 602 | + } | |
| 603 | + } | |
| 604 | + | |
| 605 | + private fun sgr() { | |
| 606 | + if (paramCount == 0) { | |
| 607 | + fg = TermColor.DEFAULT | |
| 608 | + bg = TermColor.DEFAULT | |
| 609 | + attrs = 0 | |
| 610 | + return | |
| 611 | + } | |
| 612 | + var i = 0 | |
| 613 | + while (i < paramCount) { | |
| 614 | + val p = raw(i, 0) | |
| 615 | + when (p) { | |
| 616 | + 0 -> { | |
| 617 | + fg = TermColor.DEFAULT | |
| 618 | + bg = TermColor.DEFAULT | |
| 619 | + attrs = 0 | |
| 620 | + } | |
| 621 | + 1 -> attrs = attrs or Attr.BOLD | |
| 622 | + 2 -> attrs = attrs or Attr.DIM | |
| 623 | + 3 -> attrs = attrs or Attr.ITALIC | |
| 624 | + 4 -> attrs = if (i + 1 < paramCount && colon[i + 1]) { | |
| 625 | + // `4:0` turns underline off; `4:3` and the like are styles of it. | |
| 626 | + i++ | |
| 627 | + if (raw(i, 1) == 0) attrs and Attr.UNDERLINE.inv() else attrs or Attr.UNDERLINE | |
| 628 | + } else { | |
| 629 | + attrs or Attr.UNDERLINE | |
| 630 | + } | |
| 631 | + 7 -> attrs = attrs or Attr.INVERSE | |
| 632 | + 8 -> attrs = attrs or Attr.HIDDEN | |
| 633 | + 9 -> attrs = attrs or Attr.STRIKE | |
| 634 | + 21 -> attrs = attrs or Attr.UNDERLINE | |
| 635 | + 22 -> attrs = attrs and (Attr.BOLD or Attr.DIM).inv() | |
| 636 | + 23 -> attrs = attrs and Attr.ITALIC.inv() | |
| 637 | + 24 -> attrs = attrs and Attr.UNDERLINE.inv() | |
| 638 | + 27 -> attrs = attrs and Attr.INVERSE.inv() | |
| 639 | + 28 -> attrs = attrs and Attr.HIDDEN.inv() | |
| 640 | + 29 -> attrs = attrs and Attr.STRIKE.inv() | |
| 641 | + in 30..37 -> fg = TermColor.indexed(p - 30) | |
| 642 | + 39 -> fg = TermColor.DEFAULT | |
| 643 | + in 40..47 -> bg = TermColor.indexed(p - 40) | |
| 644 | + 49 -> bg = TermColor.DEFAULT | |
| 645 | + in 90..97 -> fg = TermColor.indexed(p - 90 + 8) | |
| 646 | + in 100..107 -> bg = TermColor.indexed(p - 100 + 8) | |
| 647 | + 38, 48, 58 -> { | |
| 648 | + val (color, used) = extendedColor(i) | |
| 649 | + if (color != null) { | |
| 650 | + if (p == 38) fg = color else if (p == 48) bg = color | |
| 651 | + } | |
| 652 | + i += used | |
| 653 | + } | |
| 654 | + } | |
| 655 | + i++ | |
| 656 | + } | |
| 657 | + } | |
| 658 | + | |
| 659 | + /** `38;5;n`, `38;2;r;g;b` and their colon forms; returns the color and how many parameters it took. */ | |
| 660 | + private fun extendedColor(at: Int): Pair<Int?, Int> { | |
| 661 | + val colonForm = at + 1 < paramCount && colon[at + 1] | |
| 662 | + if (colonForm) { | |
| 663 | + var end = at + 1 | |
| 664 | + while (end + 1 < paramCount && colon[end + 1]) end++ | |
| 665 | + val count = end - at | |
| 666 | + val kind = raw(at + 1, -1) | |
| 667 | + return when { | |
| 668 | + kind == 5 && count >= 2 -> TermColor.indexed(raw(at + 2, 0)) to count | |
| 669 | + kind == 2 && count >= 5 -> TermColor.rgb(raw(end - 2, 0), raw(end - 1, 0), raw(end, 0)) to count | |
| 670 | + kind == 2 && count == 4 -> TermColor.rgb(raw(at + 2, 0), raw(at + 3, 0), raw(at + 4, 0)) to count | |
| 671 | + else -> null to count | |
| 672 | + } | |
| 673 | + } | |
| 674 | + return when (raw(at + 1, -1)) { | |
| 675 | + 5 -> if (at + 2 < paramCount) TermColor.indexed(raw(at + 2, 0)) to 2 else null to 1 | |
| 676 | + 2 -> if (at + 4 < paramCount) TermColor.rgb(raw(at + 2, 0), raw(at + 3, 0), raw(at + 4, 0)) to 4 else null to (paramCount - at - 1) | |
| 677 | + else -> null to 0 | |
| 678 | + } | |
| 679 | + } | |
| 680 | + | |
| 681 | + private fun privateMode(mode: Int, on: Boolean) { | |
| 682 | + when (mode) { | |
| 683 | + 1 -> applicationCursor = on | |
| 684 | + 6 -> { | |
| 685 | + originMode = on | |
| 686 | + moveTo(if (on) scrollTop else 0, 0) | |
| 687 | + } | |
| 688 | + 7 -> autoWrap = on | |
| 689 | + 25 -> cursorVisible = on | |
| 690 | + 47, 1047 -> switchScreen(on, clear = mode == 1047 && !on) | |
| 691 | + 1048 -> if (on) saveCursor() else restoreCursor() | |
| 692 | + 1049 -> if (on) { | |
| 693 | + saveCursor() | |
| 694 | + switchScreen(true, clear = false) | |
| 695 | + for (row in 0 until rows) blank(row) | |
| 696 | + } else { | |
| 697 | + switchScreen(false, clear = false) | |
| 698 | + restoreCursor() | |
| 699 | + } | |
| 700 | + 9, 1000, 1002, 1003 -> mouseTracking = if (on) mode else 0 | |
| 701 | + 1006 -> sgrMouse = on | |
| 702 | + 1007 -> alternateScroll = on | |
| 703 | + 2004 -> bracketedPaste = on | |
| 704 | + } | |
| 705 | + } | |
| 706 | + | |
| 707 | + private fun ansiMode(mode: Int, on: Boolean) { | |
| 708 | + when (mode) { | |
| 709 | + 4 -> insertMode = on | |
| 710 | + 20 -> newLineMode = on | |
| 711 | + } | |
| 712 | + } | |
| 713 | + | |
| 714 | + private fun switchScreen(alt: Boolean, clear: Boolean) { | |
| 715 | + if (alt == altScreen) return | |
| 716 | + if (!alt && clear) for (row in 0 until rows) alternate[row].clear(0, columns, TermColor.DEFAULT) | |
| 717 | + altScreen = alt | |
| 718 | + screen = if (alt) alternate else main | |
| 719 | + if (alt) displayOffset = 0 | |
| 720 | + pendingWrap = false | |
| 721 | + } | |
| 722 | + | |
| 723 | + private fun saveCursor() { | |
| 724 | + val saved = Saved(cursorRow, cursorColumn, fg, bg, attrs, originMode, g0Graphics, g1Graphics, shiftedOut) | |
| 725 | + if (altScreen) savedAlternate = saved else savedMain = saved | |
| 726 | + } | |
| 727 | + | |
| 728 | + private fun restoreCursor() { | |
| 729 | + val saved = (if (altScreen) savedAlternate else savedMain) | |
| 730 | + if (saved == null) { | |
| 731 | + moveTo(0, 0) | |
| 732 | + return | |
| 733 | + } | |
| 734 | + cursorRow = saved.row.coerceIn(0, rows - 1) | |
| 735 | + cursorColumn = saved.column.coerceIn(0, columns - 1) | |
| 736 | + fg = saved.fg | |
| 737 | + bg = saved.bg | |
| 738 | + attrs = saved.attrs | |
| 739 | + originMode = saved.origin | |
| 740 | + g0Graphics = saved.g0 | |
| 741 | + g1Graphics = saved.g1 | |
| 742 | + shiftedOut = saved.shifted | |
| 743 | + pendingWrap = false | |
| 744 | + } | |
| 745 | + | |
| 746 | + private fun reportCursor(private: Boolean) { | |
| 747 | + val row = if (originMode) cursorRow - scrollTop else cursorRow | |
| 748 | + reply("\u001b[${if (private) "?" else ""}${row + 1};${cursorColumn + 1}R") | |
| 749 | + } | |
| 750 | + | |
| 751 | + private fun softReset() { | |
| 752 | + cursorVisible = true | |
| 753 | + originMode = false | |
| 754 | + autoWrap = true | |
| 755 | + insertMode = false | |
| 756 | + applicationCursor = false | |
| 757 | + scrollTop = 0 | |
| 758 | + scrollBottom = rows - 1 | |
| 759 | + fg = TermColor.DEFAULT | |
| 760 | + bg = TermColor.DEFAULT | |
| 761 | + attrs = 0 | |
| 762 | + g0Graphics = false | |
| 763 | + g1Graphics = false | |
| 764 | + shiftedOut = false | |
| 765 | + savedMain = null | |
| 766 | + savedAlternate = null | |
| 767 | + } | |
| 768 | + | |
| 769 | + /** RIS: as the terminal started, scrollback included. */ | |
| 770 | + fun reset() { | |
| 771 | + softReset() | |
| 772 | + switchScreen(false, clear = true) | |
| 773 | + for (row in 0 until rows) blank(row) | |
| 774 | + history.clear() | |
| 775 | + displayOffset = 0 | |
| 776 | + cursorRow = 0 | |
| 777 | + cursorColumn = 0 | |
| 778 | + pendingWrap = false | |
| 779 | + bracketedPaste = false | |
| 780 | + mouseTracking = 0 | |
| 781 | + sgrMouse = false | |
| 782 | + alternateScroll = true | |
| 783 | + newLineMode = false | |
| 784 | + cursorShape = CursorShape.Block | |
| 785 | + resetTabs() | |
| 786 | + } | |
| 787 | + | |
| 788 | + private fun resetTabs() { | |
| 789 | + tabStops = BooleanArray(columns) { it % 8 == 0 && it > 0 } | |
| 790 | + } | |
| 791 | + | |
| 792 | + private fun reply(text: String) = respond(text.toByteArray()) | |
| 793 | + | |
| 794 | + // ---- OSC ---- | |
| 795 | + | |
| 796 | + private fun oscChar(c: Int) { | |
| 797 | + when { | |
| 798 | + c == 0x07 -> endOsc("\u0007") | |
| 799 | + oscEscape -> { | |
| 800 | + oscEscape = false | |
| 801 | + if (c == '\\'.code) endOsc("\u001b\\") else { | |
| 802 | + // ESC starts something new: the OSC is over. | |
| 803 | + state = State.Escape | |
| 804 | + escape(c) | |
| 805 | + } | |
| 806 | + } | |
| 807 | + c == 0x1b -> oscEscape = true | |
| 808 | + osc.length < MAX_OSC -> osc.appendCodePoint(c) | |
| 809 | + } | |
| 810 | + } | |
| 811 | + | |
| 812 | + private fun endOsc(terminator: String) { | |
| 813 | + state = State.Ground | |
| 814 | + val text = osc.toString() | |
| 815 | + osc.setLength(0) | |
| 816 | + val parts = text.split(';') | |
| 817 | + // Titles (0, 1, 2) and the rest are ignored; color queries are answered. | |
| 818 | + when (parts.getOrNull(0)) { | |
| 819 | + "4" -> { | |
| 820 | + var i = 1 | |
| 821 | + while (i + 1 < parts.size) { | |
| 822 | + val index = parts[i].toIntOrNull() | |
| 823 | + if (index != null && parts[i + 1] == "?") answerColor("4;$index", index, terminator) | |
| 824 | + i += 2 | |
| 825 | + } | |
| 826 | + } | |
| 827 | + "10", "11", "12" -> if (parts.getOrNull(1) == "?") { | |
| 828 | + val index = when (parts[0]) { | |
| 829 | + "10" -> 256 | |
| 830 | + "11" -> 257 | |
| 831 | + else -> 258 | |
| 832 | + } | |
| 833 | + answerColor(parts[0], index, terminator) | |
| 834 | + } | |
| 835 | + } | |
| 836 | + } | |
| 837 | + | |
| 838 | + private fun answerColor(prefix: String, index: Int, terminator: String) { | |
| 839 | + val rgb = colorOf(index) ?: return | |
| 840 | + fun channel(shift: Int): String { | |
| 841 | + val value = (rgb shr shift) and 0xff | |
| 842 | + return "%02x%02x".format(value, value) | |
| 843 | + } | |
| 844 | + reply("\u001b]$prefix;rgb:${channel(16)}/${channel(8)}/${channel(0)}$terminator") | |
| 845 | + } | |
| 846 | + | |
| 847 | + // ---- size ---- | |
| 848 | + | |
| 849 | + /** | |
| 850 | + * Fits the screen to a new size. Lines that no longer fit above the | |
| 851 | + * cursor go into the scrollback, and come back when the screen grows. | |
| 852 | + */ | |
| 853 | + fun resize(newColumns: Int, newRows: Int) { | |
| 854 | + val columns = newColumns.coerceAtLeast(1) | |
| 855 | + val rows = newRows.coerceAtLeast(1) | |
| 856 | + if (columns == this.columns && rows == this.rows) return | |
| 857 | + if (columns != this.columns) { | |
| 858 | + for (line in main) line.resize(columns) | |
| 859 | + for (line in alternate) line.resize(columns) | |
| 860 | + // Scrollback lines keep their width until they come back on screen. | |
| 861 | + this.columns = columns | |
| 862 | + } | |
| 863 | + if (rows != this.rows) { | |
| 864 | + resizeRows(main, rows, !altScreen, withHistory = true) | |
| 865 | + resizeRows(alternate, rows, altScreen, withHistory = false) | |
| 866 | + // The cursor saved on the main screen moves with its lines. | |
| 867 | + this.rows = rows | |
| 868 | + } | |
| 869 | + scrollTop = 0 | |
| 870 | + scrollBottom = this.rows - 1 | |
| 871 | + resetTabs() | |
| 872 | + cursorRow = cursorRow.coerceIn(0, this.rows - 1) | |
| 873 | + cursorColumn = cursorColumn.coerceIn(0, this.columns - 1) | |
| 874 | + pendingWrap = false | |
| 875 | + displayOffset = displayOffset.coerceAtMost(history.size) | |
| 876 | + } | |
| 877 | + | |
| 878 | + private fun resizeRows(lines: ArrayList<Line>, rows: Int, active: Boolean, withHistory: Boolean) { | |
| 879 | + val old = lines.size | |
| 880 | + var shift = 0 | |
| 881 | + if (rows < old) { | |
| 882 | + var excess = old - rows | |
| 883 | + // Blank lines below the cursor go first. | |
| 884 | + val cursor = if (active) cursorRow else savedRow(withHistory) ?: (old - 1) | |
| 885 | + while (excess > 0 && lines.size - 1 > cursor && lines.last().isBlank()) { | |
| 886 | + lines.removeAt(lines.size - 1) | |
| 887 | + excess-- | |
| 888 | + } | |
| 889 | + while (excess > 0) { | |
| 890 | + val top = lines.removeAt(0) | |
| 891 | + if (withHistory) { | |
| 892 | + history.addLast(top) | |
| 893 | + if (history.size > scrollbackLimit) history.removeFirst() | |
| 894 | + } | |
| 895 | + shift-- | |
| 896 | + excess-- | |
| 897 | + } | |
| 898 | + } else if (rows > old) { | |
| 899 | + var missing = rows - old | |
| 900 | + while (missing > 0 && withHistory && history.isNotEmpty() && lines.size > 0) { | |
| 901 | + val line = history.removeLast() | |
| 902 | + line.resize(columns) | |
| 903 | + lines.add(0, line) | |
| 904 | + shift++ | |
| 905 | + missing-- | |
| 906 | + } | |
| 907 | + while (missing > 0) { | |
| 908 | + lines.add(Line(columns)) | |
| 909 | + missing-- | |
| 910 | + } | |
| 911 | + } | |
| 912 | + if (shift != 0) { | |
| 913 | + if (active) cursorRow = (cursorRow + shift).coerceIn(0, rows - 1) | |
| 914 | + adjustSaved(withHistory, shift, rows) | |
| 915 | + } | |
| 916 | + } | |
| 917 | + | |
| 918 | + private fun savedRow(main: Boolean): Int? = (if (main) savedMain else savedAlternate)?.row | |
| 919 | + | |
| 920 | + private fun adjustSaved(main: Boolean, shift: Int, rows: Int) { | |
| 921 | + val saved = (if (main) savedMain else savedAlternate) ?: return | |
| 922 | + val moved = Saved( | |
| 923 | + (saved.row + shift).coerceIn(0, rows - 1), saved.column, saved.fg, saved.bg, saved.attrs, | |
| 924 | + saved.origin, saved.g0, saved.g1, saved.shifted, | |
| 925 | + ) | |
| 926 | + if (main) savedMain = moved else savedAlternate = moved | |
| 927 | + } | |
| 928 | + | |
| 929 | + // ---- reading ---- | |
| 930 | + | |
| 931 | + /** The line shown at screen row [row], counting the scrollback the view is scrolled into. */ | |
| 932 | + fun visibleLine(row: Int): Line { | |
| 933 | + if (displayOffset == 0) return screen[row] | |
| 934 | + val index = history.size - displayOffset + row | |
| 935 | + return if (index < history.size) history[index] else screen[index - history.size] | |
| 936 | + } | |
| 937 | + | |
| 938 | + /** A line of the screen itself, ignoring the scrollback. */ | |
| 939 | + fun screenLine(row: Int): Line = screen[row] | |
| 940 | + | |
| 941 | + /** Scrolls the view [lines] up into the scrollback (negative: back down). */ | |
| 942 | + fun scrollDisplay(lines: Int) { | |
| 943 | + if (altScreen) return | |
| 944 | + displayOffset = (displayOffset + lines).coerceIn(0, history.size) | |
| 945 | + } | |
| 946 | + | |
| 947 | + fun scrollToBottom() { | |
| 948 | + displayOffset = 0 | |
| 949 | + } | |
| 950 | + | |
| 951 | + /** What the screen shows now, wrapped lines joined. */ | |
| 952 | + fun screenText(): String = joinLines((0 until rows).map { visibleLine(it) }) | |
| 953 | + | |
| 954 | + /** The scrollback and the screen. */ | |
| 955 | + fun allText(): String = joinLines(if (altScreen) screen.toList() else history.toList() + screen) | |
| 956 | + | |
| 957 | + private fun joinLines(lines: List<Line>): String { | |
| 958 | + val builder = StringBuilder() | |
| 959 | + for ((ix, line) in lines.withIndex()) { | |
| 960 | + builder.append(line.text(trim = !line.wrapped)) | |
| 961 | + if (!line.wrapped && ix < lines.size - 1) builder.append('\n') | |
| 962 | + } | |
| 963 | + return builder.toString().trimEnd('\n', ' ') | |
| 964 | + } | |
| 965 | + | |
| 966 | + companion object { | |
| 967 | + /** How many lines a terminal keeps above the screen, as the desktop's. */ | |
| 968 | + const val SCROLLBACK = 10_000 | |
| 969 | + private const val MAX_PARAMS = 32 | |
| 970 | + private const val MAX_OSC = 4096 | |
| 971 | + | |
| 972 | + /** DEC special graphics, for `0x5f..0x7e` once `ESC ( 0` picked it. */ | |
| 973 | + private const val DEC_GRAPHICS = " ◆▒␉␌␍␊°±␋┘┐┌└┼⎺⎻─⎼⎽├┤┴┬│≤≥π≠£·" | |
| 974 | + } | |
| 975 | + } | |
| @@ -0,0 +1,181 @@ | ||
| 1 | + package cc.vectrum.code.term | |
| 2 | + | |
| 3 | + /** | |
| 4 | + * Decodes UTF-8 as it streams in: a character split between two chunks | |
| 5 | + * comes out whole, and a broken sequence comes out as U+FFFD. | |
| 6 | + */ | |
| 7 | + class Utf8Decoder { | |
| 8 | + private var codePoint = 0 | |
| 9 | + private var needed = 0 | |
| 10 | + private var minimum = 0 | |
| 11 | + | |
| 12 | + /** Feeds bytes; [emit] gets each code point they complete. */ | |
| 13 | + inline fun decode(bytes: ByteArray, offset: Int, length: Int, emit: (Int) -> Unit) { | |
| 14 | + for (i in offset until offset + length) { | |
| 15 | + val byte = bytes[i].toInt() and 0xff | |
| 16 | + var result = push(byte) | |
| 17 | + if (result == RETRY) { | |
| 18 | + emit(REPLACEMENT) | |
| 19 | + result = push(byte) | |
| 20 | + } | |
| 21 | + if (result >= 0) emit(result) else if (result == INVALID) emit(REPLACEMENT) | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** A code point, [MORE] while one is incomplete, [INVALID], or [RETRY] when this byte starts afresh. */ | |
| 26 | + fun push(byte: Int): Int { | |
| 27 | + if (needed == 0) { | |
| 28 | + return when { | |
| 29 | + byte < 0x80 -> byte | |
| 30 | + byte in 0xc2..0xdf -> start(byte and 0x1f, 1, 0x80) | |
| 31 | + byte in 0xe0..0xef -> start(byte and 0x0f, 2, 0x800) | |
| 32 | + byte in 0xf0..0xf4 -> start(byte and 0x07, 3, 0x10000) | |
| 33 | + else -> INVALID | |
| 34 | + } | |
| 35 | + } | |
| 36 | + if (byte and 0xc0 != 0x80) { | |
| 37 | + // The sequence broke off; this byte begins something new. | |
| 38 | + needed = 0 | |
| 39 | + return RETRY | |
| 40 | + } | |
| 41 | + codePoint = (codePoint shl 6) or (byte and 0x3f) | |
| 42 | + needed-- | |
| 43 | + if (needed > 0) return MORE | |
| 44 | + val done = codePoint | |
| 45 | + return if (done < minimum || done > 0x10ffff || done in 0xd800..0xdfff) INVALID else done | |
| 46 | + } | |
| 47 | + | |
| 48 | + private fun start(bits: Int, more: Int, min: Int): Int { | |
| 49 | + codePoint = bits | |
| 50 | + needed = more | |
| 51 | + minimum = min | |
| 52 | + return MORE | |
| 53 | + } | |
| 54 | + | |
| 55 | + companion object { | |
| 56 | + const val REPLACEMENT = 0xfffd | |
| 57 | + const val MORE = -1 | |
| 58 | + const val INVALID = -2 | |
| 59 | + const val RETRY = -3 | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * How many cells a character takes: 0 for combining marks and other | |
| 65 | + * zero-width characters, 2 for East Asian wide characters and emoji, 1 | |
| 66 | + * for the rest. A compact take on `wcwidth`. | |
| 67 | + */ | |
| 68 | + fun charWidth(c: Int): Int { | |
| 69 | + if (c < 0x300) return 1 | |
| 70 | + when (Character.getType(c).toByte()) { | |
| 71 | + Character.NON_SPACING_MARK, Character.ENCLOSING_MARK, Character.FORMAT -> return 0 | |
| 72 | + } | |
| 73 | + if (c == 0x200b || c in 0xfe00..0xfe0f || c in 0xe0100..0xe01ef) return 0 | |
| 74 | + return if (isWide(c)) 2 else 1 | |
| 75 | + } | |
| 76 | + | |
| 77 | + private val WIDE_RANGES = intArrayOf( | |
| 78 | + 0x1100, 0x115f, | |
| 79 | + 0x231a, 0x231b, | |
| 80 | + 0x2329, 0x232a, | |
| 81 | + 0x23e9, 0x23ec, | |
| 82 | + 0x23f0, 0x23f0, | |
| 83 | + 0x23f3, 0x23f3, | |
| 84 | + 0x25fd, 0x25fe, | |
| 85 | + 0x2614, 0x2615, | |
| 86 | + 0x2648, 0x2653, | |
| 87 | + 0x267f, 0x267f, | |
| 88 | + 0x2693, 0x2693, | |
| 89 | + 0x26a1, 0x26a1, | |
| 90 | + 0x26aa, 0x26ab, | |
| 91 | + 0x26bd, 0x26be, | |
| 92 | + 0x26c4, 0x26c5, | |
| 93 | + 0x26ce, 0x26ce, | |
| 94 | + 0x26d4, 0x26d4, | |
| 95 | + 0x26ea, 0x26ea, | |
| 96 | + 0x26f2, 0x26f3, | |
| 97 | + 0x26f5, 0x26f5, | |
| 98 | + 0x26fa, 0x26fa, | |
| 99 | + 0x26fd, 0x26fd, | |
| 100 | + 0x2705, 0x2705, | |
| 101 | + 0x270a, 0x270b, | |
| 102 | + 0x2728, 0x2728, | |
| 103 | + 0x274c, 0x274c, | |
| 104 | + 0x274e, 0x274e, | |
| 105 | + 0x2753, 0x2755, | |
| 106 | + 0x2757, 0x2757, | |
| 107 | + 0x2795, 0x2797, | |
| 108 | + 0x27b0, 0x27b0, | |
| 109 | + 0x27bf, 0x27bf, | |
| 110 | + 0x2b1b, 0x2b1c, | |
| 111 | + 0x2b50, 0x2b50, | |
| 112 | + 0x2b55, 0x2b55, | |
| 113 | + 0x2e80, 0x303e, | |
| 114 | + 0x3041, 0x33ff, | |
| 115 | + 0x3400, 0x4dbf, | |
| 116 | + 0x4e00, 0x9fff, | |
| 117 | + 0xa000, 0xa4cf, | |
| 118 | + 0xa960, 0xa97f, | |
| 119 | + 0xac00, 0xd7a3, | |
| 120 | + 0xf900, 0xfaff, | |
| 121 | + 0xfe10, 0xfe19, | |
| 122 | + 0xfe30, 0xfe6f, | |
| 123 | + 0xff00, 0xff60, | |
| 124 | + 0xffe0, 0xffe6, | |
| 125 | + 0x16fe0, 0x18cff, | |
| 126 | + 0x1b000, 0x1b2ff, | |
| 127 | + 0x1f004, 0x1f004, | |
| 128 | + 0x1f0cf, 0x1f0cf, | |
| 129 | + 0x1f18e, 0x1f18e, | |
| 130 | + 0x1f191, 0x1f19a, | |
| 131 | + 0x1f200, 0x1f251, | |
| 132 | + 0x1f300, 0x1f320, | |
| 133 | + 0x1f32d, 0x1f335, | |
| 134 | + 0x1f337, 0x1f37c, | |
| 135 | + 0x1f37e, 0x1f393, | |
| 136 | + 0x1f3a0, 0x1f3ca, | |
| 137 | + 0x1f3cf, 0x1f3d3, | |
| 138 | + 0x1f3e0, 0x1f3f0, | |
| 139 | + 0x1f3f4, 0x1f3f4, | |
| 140 | + 0x1f3f8, 0x1f43e, | |
| 141 | + 0x1f440, 0x1f440, | |
| 142 | + 0x1f442, 0x1f4fc, | |
| 143 | + 0x1f4ff, 0x1f53d, | |
| 144 | + 0x1f54b, 0x1f54e, | |
| 145 | + 0x1f550, 0x1f567, | |
| 146 | + 0x1f57a, 0x1f57a, | |
| 147 | + 0x1f595, 0x1f596, | |
| 148 | + 0x1f5a4, 0x1f5a4, | |
| 149 | + 0x1f5fb, 0x1f64f, | |
| 150 | + 0x1f680, 0x1f6c5, | |
| 151 | + 0x1f6cc, 0x1f6cc, | |
| 152 | + 0x1f6d0, 0x1f6d2, | |
| 153 | + 0x1f6d5, 0x1f6d7, | |
| 154 | + 0x1f6eb, 0x1f6ec, | |
| 155 | + 0x1f6f4, 0x1f6fc, | |
| 156 | + 0x1f7e0, 0x1f7eb, | |
| 157 | + 0x1f90c, 0x1f93a, | |
| 158 | + 0x1f93c, 0x1f945, | |
| 159 | + 0x1f947, 0x1f9ff, | |
| 160 | + 0x1fa70, 0x1faff, | |
| 161 | + 0x20000, 0x2fffd, | |
| 162 | + 0x30000, 0x3fffd, | |
| 163 | + ) | |
| 164 | + | |
| 165 | + private fun isWide(c: Int): Boolean { | |
| 166 | + if (c < WIDE_RANGES[0]) return false | |
| 167 | + // Binary search over the range pairs. | |
| 168 | + var low = 0 | |
| 169 | + var high = WIDE_RANGES.size / 2 - 1 | |
| 170 | + while (low <= high) { | |
| 171 | + val mid = (low + high) ushr 1 | |
| 172 | + val start = WIDE_RANGES[mid * 2] | |
| 173 | + val end = WIDE_RANGES[mid * 2 + 1] | |
| 174 | + when { | |
| 175 | + c < start -> high = mid - 1 | |
| 176 | + c > end -> low = mid + 1 | |
| 177 | + else -> return true | |
| 178 | + } | |
| 179 | + } | |
| 180 | + return false | |
| 181 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.