Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 195 changed files with 4506 additions and 0 deletions.
| @@ -0,0 +1,6 @@ | ||
| 1 | + .gradle/ | |
| 2 | + .kotlin/ | |
| 3 | + build/ | |
| 4 | + local.properties | |
| 5 | + *.iml | |
| 6 | + .idea/ | |
| @@ -0,0 +1,43 @@ | ||
| 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 | + testImplementation(libs.junit) | |
| 43 | + } | |
| @@ -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,34 @@ | ||
| 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 | + <activity | |
| 17 | + android:name=".MainActivity" | |
| 18 | + android:exported="true" | |
| 19 | + android:windowSoftInputMode="adjustResize" | |
| 20 | + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|density|uiMode"> | |
| 21 | + <intent-filter> | |
| 22 | + <action android:name="android.intent.action.MAIN" /> | |
| 23 | + <category android:name="android.intent.category.LAUNCHER" /> | |
| 24 | + </intent-filter> | |
| 25 | + <!-- A pairing code as a link: vectrum://192.168.2.10:47823/<key> --> | |
| 26 | + <intent-filter> | |
| 27 | + <action android:name="android.intent.action.VIEW" /> | |
| 28 | + <category android:name="android.intent.category.DEFAULT" /> | |
| 29 | + <category android:name="android.intent.category.BROWSABLE" /> | |
| 30 | + <data android:scheme="vectrum" /> | |
| 31 | + </intent-filter> | |
| 32 | + </activity> | |
| 33 | + </application> | |
| 34 | + </manifest> | |
| @@ -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,410 @@ | ||
| 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 | + val models = instances.find { it.id == instance }?.models ?: return null | |
| 297 | + return models.find { it.id == id } ?: models.find { it.resolved == id } | |
| 298 | + } | |
| 299 | + | |
| 300 | + // ---- requests -------------------------------------------------------------- | |
| 301 | + | |
| 302 | + suspend fun listFolders(path: String): ServerMessage.Folders { | |
| 303 | + val reply = CompletableDeferred<ServerMessage.Folders>() | |
| 304 | + folderRequests[path]?.complete(ServerMessage.Folders(path = path, error = "Asked again.")) | |
| 305 | + folderRequests[path] = reply | |
| 306 | + if (!send(ClientMessage.ListFolders(path))) { | |
| 307 | + folderRequests.remove(path) | |
| 308 | + return ServerMessage.Folders(path = path, error = "$name is offline.") | |
| 309 | + } | |
| 310 | + return reply.await() | |
| 311 | + } | |
| 312 | + | |
| 313 | + suspend fun diff(thread: String, base: String, head: String, path: String?, ignoreWhitespace: Boolean): ServerMessage.DiffReply { | |
| 314 | + val request = nextRequest++ | |
| 315 | + val reply = CompletableDeferred<ServerMessage.DiffReply>() | |
| 316 | + diffRequests[request] = reply | |
| 317 | + if (!send(ClientMessage.Diff(request, thread, base, head, path, ignoreWhitespace))) { | |
| 318 | + diffRequests.remove(request) | |
| 319 | + return ServerMessage.DiffReply(request, error = "$name is offline.") | |
| 320 | + } | |
| 321 | + return reply.await() | |
| 322 | + } | |
| 323 | + | |
| 324 | + /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */ | |
| 325 | + suspend fun device(call: JsonObject): ServerMessage.DeviceReply { | |
| 326 | + val request = nextRequest++ | |
| 327 | + val reply = CompletableDeferred<ServerMessage.DeviceReply>() | |
| 328 | + deviceRequests[request] = reply | |
| 329 | + if (!send(ClientMessage.Device(request, call))) { | |
| 330 | + deviceRequests.remove(request) | |
| 331 | + return ServerMessage.DeviceReply(request, error = "$name is offline.") | |
| 332 | + } | |
| 333 | + return reply.await() | |
| 334 | + } | |
| 335 | + | |
| 336 | + fun deviceCall(name: String, vararg fields: Pair<String, JsonElement>): JsonObject = | |
| 337 | + JsonObject(mapOf("call" to JsonPrimitive(name)) + fields) | |
| 338 | + | |
| 339 | + // ---- terminals ------------------------------------------------------------- | |
| 340 | + | |
| 341 | + /** Starts a shell on the host in a thread's folder; returns its id, or `null` while offline. */ | |
| 342 | + fun openTerminal(thread: String?, project: String, columns: Int, lines: Int, listener: TerminalListener): Long? { | |
| 343 | + val terminal = nextRequest++ | |
| 344 | + terminals[terminal] = listener | |
| 345 | + if (!send(ClientMessage.TerminalOpen(terminal, thread, project, columns, lines))) { | |
| 346 | + terminals.remove(terminal) | |
| 347 | + return null | |
| 348 | + } | |
| 349 | + return terminal | |
| 350 | + } | |
| 351 | + | |
| 352 | + fun terminalInput(terminal: Long, bytes: ByteArray) { | |
| 353 | + send(ClientMessage.TerminalInput(terminal, android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP))) | |
| 354 | + } | |
| 355 | + | |
| 356 | + fun resizeTerminal(terminal: Long, columns: Int, lines: Int) = send(ClientMessage.TerminalResize(terminal, columns, lines)) | |
| 357 | + | |
| 358 | + fun closeTerminal(terminal: Long) { | |
| 359 | + terminals.remove(terminal) | |
| 360 | + send(ClientMessage.TerminalClose(terminal)) | |
| 361 | + } | |
| 362 | + } | |
| 363 | + | |
| 364 | + interface TerminalListener { | |
| 365 | + fun onOutput(bytes: ByteArray) | |
| 366 | + | |
| 367 | + fun onExit(error: String?) | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** A thread that's open, with its transcript kept current by the host. */ | |
| 371 | + class OpenThread(val host: HostState, val id: String) { | |
| 372 | + var summary by mutableStateOf(host.threads[id] ?: ThreadSummary(id = id)) | |
| 373 | + internal set | |
| 374 | + var detail by mutableStateOf(ThreadDetail()) | |
| 375 | + private set | |
| 376 | + val items = mutableStateListOf<SavedItem>() | |
| 377 | + | |
| 378 | + /** The host sent the transcript at least once. */ | |
| 379 | + var loaded by mutableStateOf(false) | |
| 380 | + private set | |
| 381 | + | |
| 382 | + val ref get() = ThreadRef(host.saved.id, id) | |
| 383 | + | |
| 384 | + internal fun apply(message: ServerMessage.Thread) { | |
| 385 | + summary = message.summary | |
| 386 | + detail = message.detail | |
| 387 | + val from = message.from.coerceAtMost(items.size) | |
| 388 | + // Items before `from` are as last sent; the rest are replaced. | |
| 389 | + if (from < items.size) items.removeRange(from, items.size) | |
| 390 | + items.addAll(message.items) | |
| 391 | + if (items.size > message.len) items.removeRange(message.len, items.size) | |
| 392 | + loaded = true | |
| 393 | + } | |
| 394 | + | |
| 395 | + fun act(action: ThreadAction) = host.act(id, action) | |
| 396 | + | |
| 397 | + val working: Boolean get() = summary.status == "working" || detail.thinking | |
| 398 | + | |
| 399 | + /** The project this thread runs in, if the host lists it. */ | |
| 400 | + val project: ProjectInfo? get() = host.projects.find { it.path == summary.project } | |
| 401 | + } | |
| 402 | + | |
| 403 | + /** The Folders reply's subfolders, for pickers. */ | |
| 404 | + typealias Folders = List<Folder> | |
| 405 | + | |
| 406 | + /** Patches with one value. */ | |
| 407 | + fun patchOf(key: String, value: JsonElement) = SettingsPatch(values = JsonObject(mapOf(key to value))) | |
| 408 | + | |
| 409 | + /** One diff, as fetched. */ | |
| 410 | + typealias FileDiffs = List<RemoteFileDiff> | |
| @@ -0,0 +1,87 @@ | ||
| 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 "steer", | |
| 70 | + "streaming" to "token", | |
| 71 | + "slash_skills" to true, | |
| 72 | + "inline_ui" to true, | |
| 73 | + "hide_whitespace" to false, | |
| 74 | + "diff_collapsed" to false, | |
| 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 true, | |
| 81 | + "confirm_delete" to true, | |
| 82 | + "thread_notifications" to "notifications", | |
| 83 | + "working_section" to true, | |
| 84 | + "show_settled" to true, | |
| 85 | + ) | |
| 86 | + } | |
| 87 | + } | |
| @@ -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,412 @@ | ||
| 1 | + package cc.vectrum.code.theme | |
| 2 | + | |
| 3 | + import androidx.compose.animation.core.RepeatMode | |
| 4 | + import androidx.compose.animation.core.animateFloat | |
| 5 | + import androidx.compose.animation.core.infiniteRepeatable | |
| 6 | + import androidx.compose.animation.core.rememberInfiniteTransition | |
| 7 | + import androidx.compose.animation.core.tween | |
| 8 | + import androidx.compose.foundation.background | |
| 9 | + import androidx.compose.foundation.clickable | |
| 10 | + import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 11 | + import androidx.compose.foundation.interaction.collectIsPressedAsState | |
| 12 | + import androidx.compose.foundation.layout.Arrangement | |
| 13 | + import androidx.compose.foundation.layout.Box | |
| 14 | + import androidx.compose.foundation.layout.Row | |
| 15 | + import androidx.compose.foundation.layout.RowScope | |
| 16 | + import androidx.compose.foundation.layout.Spacer | |
| 17 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 18 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 19 | + import androidx.compose.foundation.layout.height | |
| 20 | + import androidx.compose.foundation.layout.padding | |
| 21 | + import androidx.compose.foundation.layout.size | |
| 22 | + import androidx.compose.foundation.layout.width | |
| 23 | + import androidx.compose.foundation.shape.CircleShape | |
| 24 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 25 | + import androidx.compose.foundation.text.BasicText | |
| 26 | + import androidx.compose.runtime.Composable | |
| 27 | + import androidx.compose.runtime.CompositionLocalProvider | |
| 28 | + import androidx.compose.runtime.getValue | |
| 29 | + import androidx.compose.runtime.remember | |
| 30 | + import androidx.compose.runtime.staticCompositionLocalOf | |
| 31 | + import androidx.compose.ui.Alignment | |
| 32 | + import androidx.compose.ui.Modifier | |
| 33 | + import androidx.compose.ui.draw.alpha | |
| 34 | + import androidx.compose.ui.draw.drawBehind | |
| 35 | + import androidx.compose.ui.draw.dropShadow | |
| 36 | + import androidx.compose.ui.draw.innerShadow | |
| 37 | + import androidx.compose.ui.geometry.Offset | |
| 38 | + import androidx.compose.ui.graphics.Brush | |
| 39 | + import androidx.compose.ui.graphics.Color | |
| 40 | + import androidx.compose.ui.graphics.Shape | |
| 41 | + import androidx.compose.ui.graphics.shadow.Shadow | |
| 42 | + import androidx.compose.ui.semantics.Role | |
| 43 | + import androidx.compose.ui.text.TextStyle | |
| 44 | + import androidx.compose.ui.text.font.Font | |
| 45 | + import androidx.compose.ui.text.font.FontFamily | |
| 46 | + import androidx.compose.ui.text.font.FontWeight | |
| 47 | + import androidx.compose.ui.text.style.TextOverflow | |
| 48 | + import androidx.compose.ui.unit.Dp | |
| 49 | + import androidx.compose.ui.unit.DpOffset | |
| 50 | + import androidx.compose.ui.unit.TextUnit | |
| 51 | + import androidx.compose.ui.unit.dp | |
| 52 | + import androidx.compose.ui.unit.sp | |
| 53 | + import cc.vectrum.code.R | |
| 54 | + import cc.vectrum.code.ui.Lucide | |
| 55 | + import cc.vectrum.code.ui.LucideIcon | |
| 56 | + | |
| 57 | + // MetalUI, the "soft hardware" design system the desktop app is built on: | |
| 58 | + // raised caps over inset wells, Geist type and engraved mono labels. The | |
| 59 | + // sizes here are the desktop's, in dp for its px. | |
| 60 | + | |
| 61 | + val Sans = FontFamily( | |
| 62 | + Font(R.font.geist_regular, FontWeight.Normal), | |
| 63 | + Font(R.font.geist_medium, FontWeight.Medium), | |
| 64 | + Font(R.font.geist_semibold, FontWeight.SemiBold), | |
| 65 | + Font(R.font.geist_bold, FontWeight.Bold), | |
| 66 | + ) | |
| 67 | + val Mono = FontFamily( | |
| 68 | + Font(R.font.geistmono_regular, FontWeight.Normal), | |
| 69 | + Font(R.font.geistmono_medium, FontWeight.Medium), | |
| 70 | + ) | |
| 71 | + | |
| 72 | + /** A dot-matrix face for numeric readouts, MetalUI's "pixel" voice. */ | |
| 73 | + val Pixel = FontFamily(Font(R.font.geistpixel_circle, FontWeight.Normal)) | |
| 74 | + | |
| 75 | + val LocalPalette = staticCompositionLocalOf<Palette> { error("No palette") } | |
| 76 | + | |
| 77 | + /** The palette of the active theme. */ | |
| 78 | + val palette: Palette | |
| 79 | + @Composable get() = LocalPalette.current | |
| 80 | + | |
| 81 | + @Composable | |
| 82 | + fun ProvidePalette(palette: Palette, content: @Composable () -> Unit) { | |
| 83 | + CompositionLocalProvider(LocalPalette provides palette, content = content) | |
| 84 | + } | |
| 85 | + | |
| 86 | + // ---- surfaces ------------------------------------------------------------------ | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * A surface in MetalUI's recipe: its drop shadows, the fill, then its inset | |
| 90 | + * shadows. CSS paints a list's first layer on top, so layers go on in | |
| 91 | + * reverse. | |
| 92 | + */ | |
| 93 | + fun Modifier.metal(shape: Shape, fill: Brush?, layers: List<ShadowLayer>): Modifier { | |
| 94 | + var modifier = this | |
| 95 | + for (layer in layers.asReversed()) { | |
| 96 | + if (!layer.inset) modifier = modifier.dropShadow(shape, layer.toShadow()) | |
| 97 | + } | |
| 98 | + if (fill != null) modifier = modifier.background(fill, shape) | |
| 99 | + for (layer in layers.asReversed()) { | |
| 100 | + if (layer.inset) modifier = modifier.innerShadow(shape, layer.toShadow()) | |
| 101 | + } | |
| 102 | + return modifier | |
| 103 | + } | |
| 104 | + | |
| 105 | + fun Modifier.metal(shape: Shape, fill: Color, layers: List<ShadowLayer>): Modifier = | |
| 106 | + metal(shape, Brush.verticalGradient(listOf(fill, fill)), layers) | |
| 107 | + | |
| 108 | + /** CSS's blur radius is twice the Gaussian's sigma; Android's blur radius is a bit over 1.7 sigma. */ | |
| 109 | + private fun ShadowLayer.toShadow() = Shadow( | |
| 110 | + radius = (blur * 0.87f).dp, | |
| 111 | + color = color, | |
| 112 | + spread = spread.dp, | |
| 113 | + offset = DpOffset(x.dp, y.dp), | |
| 114 | + ) | |
| 115 | + | |
| 116 | + /** A large raised sheet, like vectrum.cc's page plate. */ | |
| 117 | + fun Modifier.plate(p: Palette, shape: Shape = RoundedCornerShape(18.dp)) = | |
| 118 | + metal(shape, Duo(p.sHi, p.s).brush, p.raiseLite) | |
| 119 | + | |
| 120 | + /** A small raised object sitting on a surface: cards, bubbles, badges. */ | |
| 121 | + fun Modifier.raised(p: Palette, shape: Shape) = metal(shape, p.rowOn.brush, p.raiseSm) | |
| 122 | + | |
| 123 | + /** A pressed-in field. */ | |
| 124 | + fun Modifier.well(p: Palette, shape: Shape) = metal(shape, Duo(p.wellTop, p.wellBot).brush, p.well) | |
| 125 | + | |
| 126 | + /** A gently recessed area of a sheet. */ | |
| 127 | + fun Modifier.region(p: Palette, shape: Shape) = metal(shape, p.region.brush, p.regionSh) | |
| 128 | + | |
| 129 | + /** A popup menu's body. */ | |
| 130 | + fun Modifier.menu(p: Palette, shape: Shape = RoundedCornerShape(16.dp)) = metal(shape, p.menuBg, p.raise) | |
| 131 | + | |
| 132 | + // ---- text ---------------------------------------------------------------------- | |
| 133 | + | |
| 134 | + @Composable | |
| 135 | + fun Txt( | |
| 136 | + text: String, | |
| 137 | + modifier: Modifier = Modifier, | |
| 138 | + color: Color = palette.ink, | |
| 139 | + size: TextUnit = 13.sp, | |
| 140 | + weight: FontWeight = FontWeight.Normal, | |
| 141 | + family: FontFamily = Sans, | |
| 142 | + lineHeight: TextUnit = TextUnit.Unspecified, | |
| 143 | + maxLines: Int = Int.MAX_VALUE, | |
| 144 | + overflow: TextOverflow = TextOverflow.Ellipsis, | |
| 145 | + style: TextStyle = TextStyle.Default, | |
| 146 | + ) { | |
| 147 | + BasicText( | |
| 148 | + text = text, | |
| 149 | + modifier = modifier, | |
| 150 | + style = style.merge( | |
| 151 | + TextStyle(color = color, fontSize = size, fontWeight = weight, fontFamily = family, lineHeight = lineHeight), | |
| 152 | + ), | |
| 153 | + maxLines = maxLines, | |
| 154 | + overflow = if (maxLines == Int.MAX_VALUE) TextOverflow.Clip else overflow, | |
| 155 | + ) | |
| 156 | + } | |
| 157 | + | |
| 158 | + /** Mono, uppercase, engraved: MetalUI's small label voice. */ | |
| 159 | + @Composable | |
| 160 | + fun Engraved(text: String, modifier: Modifier = Modifier, color: Color = palette.engrave) { | |
| 161 | + Txt(text.uppercase(), modifier, color = color, size = 9.5.sp, family = Mono, lineHeight = 12.sp, maxLines = 1) | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** An engraved tag with a hairline ring, like vectrum.cc's `PUBLIC`. */ | |
| 165 | + @Composable | |
| 166 | + fun Tag(text: String, modifier: Modifier = Modifier) { | |
| 167 | + val p = palette | |
| 168 | + val shape = RoundedCornerShape(5.dp) | |
| 169 | + Box( | |
| 170 | + modifier | |
| 171 | + .height(16.dp) | |
| 172 | + .innerShadow(shape, Shadow(radius = 0.dp, color = p.tagRing, spread = 0.5.dp)) | |
| 173 | + .padding(horizontal = 6.dp), | |
| 174 | + contentAlignment = Alignment.Center, | |
| 175 | + ) { Engraved(text) } | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** A small tinted badge with a project's initials, like T3 Code's. */ | |
| 179 | + @Composable | |
| 180 | + fun Badge(initials: String, tint: Int, modifier: Modifier = Modifier) { | |
| 181 | + val p = palette | |
| 182 | + val color = p.tints[Math.floorMod(tint, p.tints.size)] | |
| 183 | + Box( | |
| 184 | + modifier | |
| 185 | + .height(16.dp) | |
| 186 | + .defaultMinSize(minWidth = 20.dp) | |
| 187 | + .background(color.copy(alpha = color.alpha * if (p.dark) 0.2f else 0.13f), RoundedCornerShape(5.dp)) | |
| 188 | + .padding(horizontal = 3.dp), | |
| 189 | + contentAlignment = Alignment.Center, | |
| 190 | + ) { | |
| 191 | + Txt(initials, color = color, size = 9.sp, weight = FontWeight.Bold, family = Mono, maxLines = 1) | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + /** A keycap: `Ctrl`, `N`, `Esc`. */ | |
| 196 | + @Composable | |
| 197 | + fun Keycap(text: String, modifier: Modifier = Modifier) { | |
| 198 | + val p = palette | |
| 199 | + val shape = RoundedCornerShape(7.dp) | |
| 200 | + Box( | |
| 201 | + modifier | |
| 202 | + .height(18.dp) | |
| 203 | + .defaultMinSize(minWidth = 19.dp) | |
| 204 | + .metal(shape, p.kbd.brush, p.raiseSm) | |
| 205 | + .padding(horizontal = 5.dp), | |
| 206 | + contentAlignment = Alignment.Center, | |
| 207 | + ) { | |
| 208 | + Txt(text, color = p.ink2, size = 10.sp, weight = FontWeight.Medium, family = Mono, maxLines = 1) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** A hairline rule with the light lip MetalUI casts below it. */ | |
| 213 | + @Composable | |
| 214 | + fun Rule(modifier: Modifier = Modifier) { | |
| 215 | + val p = palette | |
| 216 | + Spacer( | |
| 217 | + modifier | |
| 218 | + .fillMaxWidth() | |
| 219 | + .height(1.dp) | |
| 220 | + .drawBehind { | |
| 221 | + drawRect(p.ruleLip, topLeft = Offset(0f, size.height), size = size) | |
| 222 | + drawRect(p.rule) | |
| 223 | + }, | |
| 224 | + ) | |
| 225 | + } | |
| 226 | + | |
| 227 | + /** A vertical hairline, for separating toolbar groups. */ | |
| 228 | + @Composable | |
| 229 | + fun VRule(modifier: Modifier = Modifier, height: Dp = 16.dp) { | |
| 230 | + Spacer(modifier.width(1.dp).height(height).background(palette.rule)) | |
| 231 | + } | |
| 232 | + | |
| 233 | + // ---- controls ------------------------------------------------------------------ | |
| 234 | + | |
| 235 | + /** A switch: a sunk track with a raised knob, lit green when on. */ | |
| 236 | + @Composable | |
| 237 | + fun Toggle(on: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, onChange: (Boolean) -> Unit) { | |
| 238 | + val p = palette | |
| 239 | + Row( | |
| 240 | + modifier | |
| 241 | + .width(38.dp) | |
| 242 | + .height(22.dp) | |
| 243 | + .then( | |
| 244 | + if (on) Modifier.metal(CircleShape, Duo(p.greenInk, p.greenDeep).brush, p.pressedSh) | |
| 245 | + else Modifier.well(p, CircleShape), | |
| 246 | + ) | |
| 247 | + .clickable(enabled = enabled, role = Role.Switch, interactionSource = null, indication = null) { onChange(!on) } | |
| 248 | + .padding(3.dp) | |
| 249 | + .alpha(if (enabled) 1f else 0.4f), | |
| 250 | + horizontalArrangement = if (on) Arrangement.End else Arrangement.Start, | |
| 251 | + verticalAlignment = Alignment.CenterVertically, | |
| 252 | + ) { | |
| 253 | + Box(Modifier.size(16.dp).metal(CircleShape, p.cap.brush, p.raiseSm)) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + /** A segmented switch: options as caps in one well, the current one raised. */ | |
| 258 | + @Composable | |
| 259 | + fun <T> Segmented(options: List<Pair<T, String>>, current: T, modifier: Modifier = Modifier, onPick: (T) -> Unit) { | |
| 260 | + val p = palette | |
| 261 | + Row( | |
| 262 | + modifier.well(p, CircleShape).padding(3.dp), | |
| 263 | + horizontalArrangement = Arrangement.spacedBy(2.dp), | |
| 264 | + ) { | |
| 265 | + for ((value, label) in options) { | |
| 266 | + val selected = value == current | |
| 267 | + Box( | |
| 268 | + Modifier | |
| 269 | + .height(24.dp) | |
| 270 | + .then(if (selected) Modifier.metal(CircleShape, p.cap.brush, p.btn) else Modifier) | |
| 271 | + .clickable(interactionSource = null, indication = null) { onPick(value) } | |
| 272 | + .padding(horizontal = 12.dp), | |
| 273 | + contentAlignment = Alignment.Center, | |
| 274 | + ) { | |
| 275 | + Txt(label, color = if (selected) p.ink else p.ink3, size = 12.5.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 276 | + } | |
| 277 | + } | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + enum class Led { Live, Waiting, Failed, Off } | |
| 282 | + | |
| 283 | + /** A small domed status light. `pulse` breathes it, for work in progress. */ | |
| 284 | + @Composable | |
| 285 | + fun StatusLed(kind: Led, modifier: Modifier = Modifier, pulse: Boolean = false, size: Dp = 6.dp) { | |
| 286 | + val p = palette | |
| 287 | + val (top, bottom, glow) = when (kind) { | |
| 288 | + Led.Live -> Triple(Color(0xffd9ffe9), Color(0xff2fb673), Color(0x8c7beaae)) | |
| 289 | + Led.Waiting -> Triple(Color(0xfffff1cf), Color(0xffc98a18), Color(0x8cf5bf55)) | |
| 290 | + Led.Failed -> Triple(Color(0xffffd9d2), Color(0xffd5392a), Color(0x8cff7a66)) | |
| 291 | + Led.Off -> Triple(p.ledOff.top, p.ledOff.bottom, null) | |
| 292 | + } | |
| 293 | + val alpha = if (pulse) { | |
| 294 | + val transition = rememberInfiniteTransition(label = "led") | |
| 295 | + val value by transition.animateFloat( | |
| 296 | + initialValue = 1f, | |
| 297 | + targetValue = 0.35f, | |
| 298 | + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), | |
| 299 | + label = "pulse", | |
| 300 | + ) | |
| 301 | + value | |
| 302 | + } else 1f | |
| 303 | + val fill = Brush.linearGradient( | |
| 304 | + 0f to top, | |
| 305 | + 0.75f to bottom, | |
| 306 | + start = Offset(0f, 0f), | |
| 307 | + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), | |
| 308 | + ) | |
| 309 | + val layers = if (glow != null) { | |
| 310 | + listOf(ShadowLayer(0f, 0f, 0f, 0.5f, false, Color(0x40000000)), ShadowLayer(0f, 0f, 3f, 0f, false, glow)) | |
| 311 | + } else p.ledOffSh | |
| 312 | + Box(modifier.size(size).alpha(alpha).metal(CircleShape, fill, layers)) | |
| 313 | + } | |
| 314 | + | |
| 315 | + enum class CapKind { | |
| 316 | + /** The everyday raised button. */ | |
| 317 | + Standard, | |
| 318 | + | |
| 319 | + /** The one action that matters: an inverted cap. */ | |
| 320 | + Primary, | |
| 321 | + Danger, | |
| 322 | + | |
| 323 | + /** No cap until it's pressed. */ | |
| 324 | + Ghost, | |
| 325 | + } | |
| 326 | + | |
| 327 | + enum class CapSize(val height: Dp, val pad: Dp, val font: TextUnit, val glyph: Dp) { | |
| 328 | + /** 32px, MetalUI's default button. */ | |
| 329 | + Regular(32.dp, 15.dp, 13.sp, 16.dp), | |
| 330 | + | |
| 331 | + /** 26px, for toolbars. */ | |
| 332 | + Compact(26.dp, 11.dp, 12.sp, 14.dp), | |
| 333 | + | |
| 334 | + /** 22px, for icon buttons tucked into rows. */ | |
| 335 | + Mini(22.dp, 8.dp, 12.sp, 13.dp), | |
| 336 | + } | |
| 337 | + | |
| 338 | + /** A MetalUI button: a pill-shaped cap that sinks into a well when pressed. */ | |
| 339 | + @Composable | |
| 340 | + fun Cap( | |
| 341 | + modifier: Modifier = Modifier, | |
| 342 | + label: String? = null, | |
| 343 | + icon: Lucide? = null, | |
| 344 | + kind: CapKind = CapKind.Standard, | |
| 345 | + size: CapSize = CapSize.Regular, | |
| 346 | + iconColor: Color? = null, | |
| 347 | + caret: Boolean = false, | |
| 348 | + selected: Boolean = false, | |
| 349 | + enabled: Boolean = true, | |
| 350 | + stretch: Boolean = false, | |
| 351 | + leading: (@Composable RowScope.() -> Unit)? = null, | |
| 352 | + onClick: () -> Unit, | |
| 353 | + ) { | |
| 354 | + val p = palette | |
| 355 | + val interaction = remember { MutableInteractionSource() } | |
| 356 | + val pressed by interaction.collectIsPressedAsState() | |
| 357 | + val ghost = kind == CapKind.Ghost | |
| 358 | + val (fill, pressedFill, shadow, pressedShadow, ink) = when (kind) { | |
| 359 | + CapKind.Standard, CapKind.Ghost -> CapLook(p.cap, p.pressed, p.btn, p.pressedSh, p.ink) | |
| 360 | + CapKind.Primary -> CapLook(p.primary, p.primaryPressed, p.primarySh, p.primaryPressedSh, p.primaryInk) | |
| 361 | + CapKind.Danger -> CapLook(p.danger, p.dangerPressed, p.dangerSh, p.dangerPressedSh, Color.White) | |
| 362 | + } | |
| 363 | + val down = enabled && pressed | |
| 364 | + val surface = when { | |
| 365 | + selected -> Modifier.metal(CircleShape, p.pressed.brush, p.pressedSh) | |
| 366 | + down && ghost -> Modifier.background(p.rowHover, CircleShape) | |
| 367 | + down -> Modifier.metal(CircleShape, pressedFill.brush, pressedShadow) | |
| 368 | + ghost -> Modifier | |
| 369 | + else -> Modifier.metal(CircleShape, fill.brush, shadow) | |
| 370 | + } | |
| 371 | + val text = when { | |
| 372 | + selected -> p.ink | |
| 373 | + ghost && !down -> p.ink2 | |
| 374 | + ghost -> p.ink | |
| 375 | + else -> ink | |
| 376 | + } | |
| 377 | + val iconOnly = label == null && leading == null | |
| 378 | + Row( | |
| 379 | + modifier | |
| 380 | + .height(size.height) | |
| 381 | + .then(if (iconOnly) Modifier.width(size.height) else Modifier) | |
| 382 | + .then(surface) | |
| 383 | + .clickable(enabled = enabled, interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick) | |
| 384 | + .then(if (iconOnly) Modifier else Modifier.padding(horizontal = size.pad)) | |
| 385 | + .alpha(if (enabled) 1f else 0.4f), | |
| 386 | + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), | |
| 387 | + verticalAlignment = Alignment.CenterVertically, | |
| 388 | + ) { | |
| 389 | + if (icon != null) LucideIcon(icon, size = size.glyph, tint = iconColor ?: text) | |
| 390 | + leading?.invoke(this) | |
| 391 | + if (label != null) { | |
| 392 | + Txt( | |
| 393 | + label, | |
| 394 | + modifier = if (stretch) Modifier.weight(1f) else Modifier, | |
| 395 | + color = text, | |
| 396 | + size = size.font, | |
| 397 | + weight = FontWeight.Medium, | |
| 398 | + lineHeight = 16.sp, | |
| 399 | + maxLines = 1, | |
| 400 | + ) | |
| 401 | + } | |
| 402 | + if (caret) LucideIcon(Lucide.ChevronDown, size = 12.dp, tint = if (ghost) p.ink3 else ink.copy(alpha = 0.6f)) | |
| 403 | + } | |
| 404 | + } | |
| 405 | + | |
| 406 | + private data class CapLook( | |
| 407 | + val fill: Duo, | |
| 408 | + val pressedFill: Duo, | |
| 409 | + val shadow: List<ShadowLayer>, | |
| 410 | + val pressedShadow: List<ShadowLayer>, | |
| 411 | + val ink: Color, | |
| 412 | + ) | |
| @@ -0,0 +1,230 @@ | ||
| 1 | + package cc.vectrum.code.theme | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import androidx.compose.runtime.Immutable | |
| 5 | + import androidx.compose.ui.graphics.Brush | |
| 6 | + import androidx.compose.ui.graphics.Color | |
| 7 | + import kotlinx.serialization.json.Json | |
| 8 | + import kotlinx.serialization.json.JsonArray | |
| 9 | + import kotlinx.serialization.json.JsonObject | |
| 10 | + import kotlinx.serialization.json.boolean | |
| 11 | + import kotlinx.serialization.json.float | |
| 12 | + import kotlinx.serialization.json.jsonArray | |
| 13 | + import kotlinx.serialization.json.jsonObject | |
| 14 | + import kotlinx.serialization.json.jsonPrimitive | |
| 15 | + | |
| 16 | + /** One layer of a CSS `box-shadow`, as MetalUI writes them. */ | |
| 17 | + @Immutable | |
| 18 | + data class ShadowLayer( | |
| 19 | + val x: Float, | |
| 20 | + val y: Float, | |
| 21 | + val blur: Float, | |
| 22 | + val spread: Float, | |
| 23 | + val inset: Boolean, | |
| 24 | + val color: Color, | |
| 25 | + ) | |
| 26 | + | |
| 27 | + /** A top-to-bottom pair of colors, CSS's `linear-gradient(top, bottom)`. */ | |
| 28 | + @Immutable | |
| 29 | + data class Duo(val top: Color, val bottom: Color) { | |
| 30 | + val brush: Brush get() = Brush.verticalGradient(listOf(top, bottom)) | |
| 31 | + } | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * One MetalUI colorway, field for field the desktop's `metal::Palette`. The | |
| 35 | + * values aren't derived here: `assets/themes.json` is exported from the | |
| 36 | + * desktop app (`cargo test export_android_themes -- --ignored`), so every | |
| 37 | + * theme matches it exactly. | |
| 38 | + */ | |
| 39 | + @Immutable | |
| 40 | + class Palette( | |
| 41 | + val dark: Boolean, | |
| 42 | + /** The table everything sits on. */ | |
| 43 | + val page: Color, | |
| 44 | + val sHi: Color, | |
| 45 | + val s: Color, | |
| 46 | + val sLo: Color, | |
| 47 | + val wellTop: Color, | |
| 48 | + val wellBot: Color, | |
| 49 | + val ink: Color, | |
| 50 | + val ink2: Color, | |
| 51 | + val ink3: Color, | |
| 52 | + val icon: Color, | |
| 53 | + /** Engraved mono labels. */ | |
| 54 | + val engrave: Color, | |
| 55 | + /** The hairline ring around an engraved tag. */ | |
| 56 | + val tagRing: Color, | |
| 57 | + val rule: Color, | |
| 58 | + /** The highlight a hairline rule casts below itself. */ | |
| 59 | + val ruleLip: Color, | |
| 60 | + val rowHover: Color, | |
| 61 | + val menuBg: Color, | |
| 62 | + val menuRowHover: Color, | |
| 63 | + val fieldInk: Color, | |
| 64 | + val cap: Duo, | |
| 65 | + val pressed: Duo, | |
| 66 | + val rowOn: Duo, | |
| 67 | + val kbd: Duo, | |
| 68 | + val primary: Duo, | |
| 69 | + val primaryPressed: Duo, | |
| 70 | + val primaryInk: Color, | |
| 71 | + val danger: Duo, | |
| 72 | + val dangerPressed: Duo, | |
| 73 | + val region: Duo, | |
| 74 | + val greenDeep: Color, | |
| 75 | + /** Green that is legible as text on this colorway's surfaces. */ | |
| 76 | + val greenInk: Color, | |
| 77 | + val red: Color, | |
| 78 | + val amber: Color, | |
| 79 | + val amberInk: Color, | |
| 80 | + val blueInk: Color, | |
| 81 | + val ledOff: Duo, | |
| 82 | + /** MetalUI's accent tints: ember, blush, tide, spark, dusk, iris. */ | |
| 83 | + val tints: List<Color>, | |
| 84 | + val raise: List<ShadowLayer>, | |
| 85 | + val raiseLite: List<ShadowLayer>, | |
| 86 | + val raiseSm: List<ShadowLayer>, | |
| 87 | + val btn: List<ShadowLayer>, | |
| 88 | + val pressedSh: List<ShadowLayer>, | |
| 89 | + val well: List<ShadowLayer>, | |
| 90 | + val regionSh: List<ShadowLayer>, | |
| 91 | + val primarySh: List<ShadowLayer>, | |
| 92 | + val primaryPressedSh: List<ShadowLayer>, | |
| 93 | + val dangerSh: List<ShadowLayer>, | |
| 94 | + val dangerPressedSh: List<ShadowLayer>, | |
| 95 | + val ledOffSh: List<ShadowLayer>, | |
| 96 | + ) | |
| 97 | + | |
| 98 | + /** A theme on the Appearance page. */ | |
| 99 | + @Immutable | |
| 100 | + class Theme(val id: String, val name: String, val palette: Palette) | |
| 101 | + | |
| 102 | + /** Themes from one color scheme, shown as one card with a swatch per variant. */ | |
| 103 | + @Immutable | |
| 104 | + class Family(val name: String, val themes: List<Theme>) | |
| 105 | + | |
| 106 | + /** Every theme the desktop app has, loaded once from `assets/themes.json`. */ | |
| 107 | + object Themes { | |
| 108 | + lateinit var all: List<Theme> | |
| 109 | + private set | |
| 110 | + lateinit var families: List<Family> | |
| 111 | + private set | |
| 112 | + | |
| 113 | + fun load(context: Context) { | |
| 114 | + if (::all.isInitialized) return | |
| 115 | + val text = context.assets.open("themes.json").bufferedReader().use { it.readText() } | |
| 116 | + val root = Json.parseToJsonElement(text).jsonObject | |
| 117 | + all = root["themes"]!!.jsonArray.map { element -> | |
| 118 | + val theme = element.jsonObject | |
| 119 | + Theme( | |
| 120 | + id = theme.string("id"), | |
| 121 | + name = theme.string("name"), | |
| 122 | + palette = parsePalette(theme["palette"]!!.jsonObject), | |
| 123 | + ) | |
| 124 | + } | |
| 125 | + families = root["families"]!!.jsonArray.map { element -> | |
| 126 | + val family = element.jsonObject | |
| 127 | + Family( | |
| 128 | + name = family.string("name"), | |
| 129 | + themes = family["themes"]!!.jsonArray.mapNotNull { id -> all.find { it.id == id.jsonPrimitive.content } }, | |
| 130 | + ) | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + /** The palette of the theme with this id, if it's of the right kind, or Graphite or Bone. */ | |
| 135 | + fun find(id: String, dark: Boolean): Palette { | |
| 136 | + val match = all.find { it.id == id && it.palette.dark == dark } | |
| 137 | + return match?.palette ?: all.first { it.id == if (dark) "graphite" else "bone" }.palette | |
| 138 | + } | |
| 139 | + | |
| 140 | + fun theme(id: String): Theme? = all.find { it.id == id } | |
| 141 | + } | |
| 142 | + | |
| 143 | + private fun JsonObject.string(key: String) = this[key]!!.jsonPrimitive.content | |
| 144 | + | |
| 145 | + /** `#rrggbbaa`, as the export writes every color. */ | |
| 146 | + fun parseHex(text: String): Color { | |
| 147 | + val digits = text.removePrefix("#") | |
| 148 | + val expanded = when (digits.length) { | |
| 149 | + 3, 4 -> digits.map { "$it$it" }.joinToString("") | |
| 150 | + else -> digits | |
| 151 | + } | |
| 152 | + val value = expanded.toLong(16) | |
| 153 | + return if (expanded.length == 6) { | |
| 154 | + Color(0xff000000 or value) | |
| 155 | + } else { | |
| 156 | + val rgb = value ushr 8 | |
| 157 | + val alpha = value and 0xff | |
| 158 | + Color((alpha shl 24) or rgb) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + private fun parsePalette(json: JsonObject): Palette { | |
| 163 | + fun color(key: String) = parseHex(json[key]!!.jsonPrimitive.content) | |
| 164 | + fun duo(key: String): Duo { | |
| 165 | + val pair = json[key]!!.jsonArray | |
| 166 | + return Duo(parseHex(pair[0].jsonPrimitive.content), parseHex(pair[1].jsonPrimitive.content)) | |
| 167 | + } | |
| 168 | + fun shadows(key: String): List<ShadowLayer> = (json[key] as JsonArray).map { element -> | |
| 169 | + val layer = element.jsonObject | |
| 170 | + ShadowLayer( | |
| 171 | + x = layer["x"]!!.jsonPrimitive.float, | |
| 172 | + y = layer["y"]!!.jsonPrimitive.float, | |
| 173 | + blur = layer["blur"]!!.jsonPrimitive.float, | |
| 174 | + spread = layer["spread"]!!.jsonPrimitive.float, | |
| 175 | + inset = layer["inset"]!!.jsonPrimitive.boolean, | |
| 176 | + color = parseHex(layer["color"]!!.jsonPrimitive.content), | |
| 177 | + ) | |
| 178 | + } | |
| 179 | + return Palette( | |
| 180 | + dark = json["dark"]!!.jsonPrimitive.boolean, | |
| 181 | + page = color("page"), | |
| 182 | + sHi = color("s_hi"), | |
| 183 | + s = color("s"), | |
| 184 | + sLo = color("s_lo"), | |
| 185 | + wellTop = color("well_top"), | |
| 186 | + wellBot = color("well_bot"), | |
| 187 | + ink = color("ink"), | |
| 188 | + ink2 = color("ink2"), | |
| 189 | + ink3 = color("ink3"), | |
| 190 | + icon = color("icon"), | |
| 191 | + engrave = color("engrave"), | |
| 192 | + tagRing = color("tag_ring"), | |
| 193 | + rule = color("rule"), | |
| 194 | + ruleLip = color("rule_lip"), | |
| 195 | + rowHover = color("row_hover"), | |
| 196 | + menuBg = color("menu_bg"), | |
| 197 | + menuRowHover = color("menu_row_hover"), | |
| 198 | + fieldInk = color("field_ink"), | |
| 199 | + cap = duo("cap"), | |
| 200 | + pressed = duo("pressed"), | |
| 201 | + rowOn = duo("row_on"), | |
| 202 | + kbd = duo("kbd"), | |
| 203 | + primary = duo("primary"), | |
| 204 | + primaryPressed = duo("primary_pressed"), | |
| 205 | + primaryInk = color("primary_ink"), | |
| 206 | + danger = duo("danger"), | |
| 207 | + dangerPressed = duo("danger_pressed"), | |
| 208 | + region = duo("region"), | |
| 209 | + greenDeep = color("green_deep"), | |
| 210 | + greenInk = color("green_ink"), | |
| 211 | + red = color("red"), | |
| 212 | + amber = color("amber"), | |
| 213 | + amberInk = color("amber_ink"), | |
| 214 | + blueInk = color("blue_ink"), | |
| 215 | + ledOff = duo("led_off"), | |
| 216 | + tints = json["tints"]!!.jsonArray.map { parseHex(it.jsonPrimitive.content) }, | |
| 217 | + raise = shadows("raise"), | |
| 218 | + raiseLite = shadows("raise_lite"), | |
| 219 | + raiseSm = shadows("raise_sm"), | |
| 220 | + btn = shadows("btn"), | |
| 221 | + pressedSh = shadows("pressed_sh"), | |
| 222 | + well = shadows("well"), | |
| 223 | + regionSh = shadows("region_sh"), | |
| 224 | + primarySh = shadows("primary_sh"), | |
| 225 | + primaryPressedSh = shadows("primary_pressed_sh"), | |
| 226 | + dangerSh = shadows("danger_sh"), | |
| 227 | + dangerPressedSh = shadows("danger_pressed_sh"), | |
| 228 | + ledOffSh = shadows("led_off_sh"), | |
| 229 | + ) | |
| 230 | + } | |
| @@ -0,0 +1,171 @@ | ||
| 1 | + package cc.vectrum.code.ui | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.size | |
| 4 | + import androidx.compose.runtime.Composable | |
| 5 | + import androidx.compose.ui.Modifier | |
| 6 | + import androidx.compose.ui.graphics.Color | |
| 7 | + import androidx.compose.ui.graphics.ColorFilter | |
| 8 | + import androidx.compose.ui.res.painterResource | |
| 9 | + import androidx.compose.ui.unit.Dp | |
| 10 | + import androidx.compose.ui.unit.dp | |
| 11 | + import androidx.compose.foundation.Image | |
| 12 | + import cc.vectrum.code.R | |
| 13 | + import cc.vectrum.code.theme.palette | |
| 14 | + | |
| 15 | + /** The Lucide icons the app uses, as the desktop draws them. Add one with scripts/lucide.py. */ | |
| 16 | + enum class Lucide(val res: Int) { | |
| 17 | + AlarmClock(R.drawable.lucide_alarm_clock), | |
| 18 | + AlarmClockOff(R.drawable.lucide_alarm_clock_off), | |
| 19 | + Archive(R.drawable.lucide_archive), | |
| 20 | + ArchiveRestore(R.drawable.lucide_archive_restore), | |
| 21 | + ArchiveX(R.drawable.lucide_archive_x), | |
| 22 | + ArrowDown(R.drawable.lucide_arrow_down), | |
| 23 | + ArrowDownToLine(R.drawable.lucide_arrow_down_to_line), | |
| 24 | + ArrowLeft(R.drawable.lucide_arrow_left), | |
| 25 | + ArrowRight(R.drawable.lucide_arrow_right), | |
| 26 | + ArrowUp(R.drawable.lucide_arrow_up), | |
| 27 | + Asterisk(R.drawable.lucide_asterisk), | |
| 28 | + AtSign(R.drawable.lucide_at_sign), | |
| 29 | + Bell(R.drawable.lucide_bell), | |
| 30 | + Blocks(R.drawable.lucide_blocks), | |
| 31 | + BookOpen(R.drawable.lucide_book_open), | |
| 32 | + Bot(R.drawable.lucide_bot), | |
| 33 | + Brain(R.drawable.lucide_brain), | |
| 34 | + Bug(R.drawable.lucide_bug), | |
| 35 | + Camera(R.drawable.lucide_camera), | |
| 36 | + ChartNoAxesColumn(R.drawable.lucide_chart_no_axes_column), | |
| 37 | + Check(R.drawable.lucide_check), | |
| 38 | + ChevronDown(R.drawable.lucide_chevron_down), | |
| 39 | + ChevronLeft(R.drawable.lucide_chevron_left), | |
| 40 | + ChevronRight(R.drawable.lucide_chevron_right), | |
| 41 | + ChevronUp(R.drawable.lucide_chevron_up), | |
| 42 | + ChevronsDownUp(R.drawable.lucide_chevrons_down_up), | |
| 43 | + ChevronsUpDown(R.drawable.lucide_chevrons_up_down), | |
| 44 | + Circle(R.drawable.lucide_circle), | |
| 45 | + CircleAlert(R.drawable.lucide_circle_alert), | |
| 46 | + CircleCheck(R.drawable.lucide_circle_check), | |
| 47 | + CircleDot(R.drawable.lucide_circle_dot), | |
| 48 | + CircleStop(R.drawable.lucide_circle_stop), | |
| 49 | + CircleX(R.drawable.lucide_circle_x), | |
| 50 | + Clock(R.drawable.lucide_clock), | |
| 51 | + Code(R.drawable.lucide_code), | |
| 52 | + Contrast(R.drawable.lucide_contrast), | |
| 53 | + Copy(R.drawable.lucide_copy), | |
| 54 | + CornerDownRight(R.drawable.lucide_corner_down_right), | |
| 55 | + Cpu(R.drawable.lucide_cpu), | |
| 56 | + DollarSign(R.drawable.lucide_dollar_sign), | |
| 57 | + Ellipsis(R.drawable.lucide_ellipsis), | |
| 58 | + EllipsisVertical(R.drawable.lucide_ellipsis_vertical), | |
| 59 | + ExternalLink(R.drawable.lucide_external_link), | |
| 60 | + Eye(R.drawable.lucide_eye), | |
| 61 | + EyeOff(R.drawable.lucide_eye_off), | |
| 62 | + Feather(R.drawable.lucide_feather), | |
| 63 | + File(R.drawable.lucide_file), | |
| 64 | + FileCode(R.drawable.lucide_file_code), | |
| 65 | + FileDiff(R.drawable.lucide_file_diff), | |
| 66 | + FilePlus(R.drawable.lucide_file_plus), | |
| 67 | + FileText(R.drawable.lucide_file_text), | |
| 68 | + Folder(R.drawable.lucide_folder), | |
| 69 | + FolderGit2(R.drawable.lucide_folder_git_2), | |
| 70 | + FolderOpen(R.drawable.lucide_folder_open), | |
| 71 | + FolderPlus(R.drawable.lucide_folder_plus), | |
| 72 | + Gauge(R.drawable.lucide_gauge), | |
| 73 | + GitBranch(R.drawable.lucide_git_branch), | |
| 74 | + GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal), | |
| 75 | + GitFork(R.drawable.lucide_git_fork), | |
| 76 | + GitPullRequest(R.drawable.lucide_git_pull_request), | |
| 77 | + Github(R.drawable.lucide_github), | |
| 78 | + Globe(R.drawable.lucide_globe), | |
| 79 | + Hammer(R.drawable.lucide_hammer), | |
| 80 | + HardDrive(R.drawable.lucide_hard_drive), | |
| 81 | + Hash(R.drawable.lucide_hash), | |
| 82 | + House(R.drawable.lucide_house), | |
| 83 | + Image(R.drawable.lucide_image), | |
| 84 | + Inbox(R.drawable.lucide_inbox), | |
| 85 | + Info(R.drawable.lucide_info), | |
| 86 | + Keyboard(R.drawable.lucide_keyboard), | |
| 87 | + Laptop(R.drawable.lucide_laptop), | |
| 88 | + Layers(R.drawable.lucide_layers), | |
| 89 | + Link2(R.drawable.lucide_link_2), | |
| 90 | + List(R.drawable.lucide_list), | |
| 91 | + ListChecks(R.drawable.lucide_list_checks), | |
| 92 | + ListOrdered(R.drawable.lucide_list_ordered), | |
| 93 | + ListTodo(R.drawable.lucide_list_todo), | |
| 94 | + LoaderCircle(R.drawable.lucide_loader_circle), | |
| 95 | + Lock(R.drawable.lucide_lock), | |
| 96 | + LockOpen(R.drawable.lucide_lock_open), | |
| 97 | + Mail(R.drawable.lucide_mail), | |
| 98 | + MapPin(R.drawable.lucide_map_pin), | |
| 99 | + MemoryStick(R.drawable.lucide_memory_stick), | |
| 100 | + MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark), | |
| 101 | + MessageSquare(R.drawable.lucide_message_square), | |
| 102 | + Mic(R.drawable.lucide_mic), | |
| 103 | + Minus(R.drawable.lucide_minus), | |
| 104 | + MonitorSmartphone(R.drawable.lucide_monitor_smartphone), | |
| 105 | + Moon(R.drawable.lucide_moon), | |
| 106 | + MoonStar(R.drawable.lucide_moon_star), | |
| 107 | + MoveLeft(R.drawable.lucide_move_left), | |
| 108 | + MoveRight(R.drawable.lucide_move_right), | |
| 109 | + Navigation(R.drawable.lucide_navigation), | |
| 110 | + Network(R.drawable.lucide_network), | |
| 111 | + Palette(R.drawable.lucide_palette), | |
| 112 | + PanelLeftClose(R.drawable.lucide_panel_left_close), | |
| 113 | + PanelLeftOpen(R.drawable.lucide_panel_left_open), | |
| 114 | + PanelRightClose(R.drawable.lucide_panel_right_close), | |
| 115 | + PanelRightOpen(R.drawable.lucide_panel_right_open), | |
| 116 | + Paperclip(R.drawable.lucide_paperclip), | |
| 117 | + Pencil(R.drawable.lucide_pencil), | |
| 118 | + Pi(R.drawable.lucide_pi), | |
| 119 | + Pin(R.drawable.lucide_pin), | |
| 120 | + PinOff(R.drawable.lucide_pin_off), | |
| 121 | + Play(R.drawable.lucide_play), | |
| 122 | + Plug(R.drawable.lucide_plug), | |
| 123 | + PlugZap(R.drawable.lucide_plug_zap), | |
| 124 | + Plus(R.drawable.lucide_plus), | |
| 125 | + Power(R.drawable.lucide_power), | |
| 126 | + QrCode(R.drawable.lucide_qr_code), | |
| 127 | + RefreshCw(R.drawable.lucide_refresh_cw), | |
| 128 | + RotateCcw(R.drawable.lucide_rotate_ccw), | |
| 129 | + RotateCw(R.drawable.lucide_rotate_cw), | |
| 130 | + ScanLine(R.drawable.lucide_scan_line), | |
| 131 | + Search(R.drawable.lucide_search), | |
| 132 | + SendHorizontal(R.drawable.lucide_send_horizontal), | |
| 133 | + Server(R.drawable.lucide_server), | |
| 134 | + Settings(R.drawable.lucide_settings), | |
| 135 | + Settings2(R.drawable.lucide_settings_2), | |
| 136 | + Shield(R.drawable.lucide_shield), | |
| 137 | + ShieldCheck(R.drawable.lucide_shield_check), | |
| 138 | + SlidersHorizontal(R.drawable.lucide_sliders_horizontal), | |
| 139 | + Smartphone(R.drawable.lucide_smartphone), | |
| 140 | + Sparkles(R.drawable.lucide_sparkles), | |
| 141 | + Square(R.drawable.lucide_square), | |
| 142 | + SquareDashed(R.drawable.lucide_square_dashed), | |
| 143 | + SquarePen(R.drawable.lucide_square_pen), | |
| 144 | + SquareStop(R.drawable.lucide_square_stop), | |
| 145 | + SquareTerminal(R.drawable.lucide_square_terminal), | |
| 146 | + Sun(R.drawable.lucide_sun), | |
| 147 | + SunMoon(R.drawable.lucide_sun_moon), | |
| 148 | + Terminal(R.drawable.lucide_terminal), | |
| 149 | + Trash(R.drawable.lucide_trash), | |
| 150 | + TriangleAlert(R.drawable.lucide_triangle_alert), | |
| 151 | + Type(R.drawable.lucide_type), | |
| 152 | + Undo2(R.drawable.lucide_undo_2), | |
| 153 | + Users(R.drawable.lucide_users), | |
| 154 | + WandSparkles(R.drawable.lucide_wand_sparkles), | |
| 155 | + Wifi(R.drawable.lucide_wifi), | |
| 156 | + WifiOff(R.drawable.lucide_wifi_off), | |
| 157 | + Workflow(R.drawable.lucide_workflow), | |
| 158 | + Wrench(R.drawable.lucide_wrench), | |
| 159 | + X(R.drawable.lucide_x), | |
| 160 | + Zap(R.drawable.lucide_zap); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Composable | |
| 164 | + fun LucideIcon(icon: Lucide, modifier: Modifier = Modifier, size: Dp = 16.dp, tint: Color = palette.icon) { | |
| 165 | + Image( | |
| 166 | + painter = painterResource(icon.res), | |
| 167 | + contentDescription = null, | |
| 168 | + modifier = modifier.size(size), | |
| 169 | + colorFilter = ColorFilter.tint(tint), | |
| 170 | + ) | |
| 171 | + } | |
| @@ -0,0 +1,192 @@ | ||
| 1 | + package cc.vectrum.code.ui | |
| 2 | + | |
| 3 | + import androidx.activity.compose.BackHandler | |
| 4 | + import androidx.compose.animation.AnimatedVisibility | |
| 5 | + import androidx.compose.animation.core.animateDpAsState | |
| 6 | + import androidx.compose.animation.core.animateFloatAsState | |
| 7 | + import androidx.compose.animation.fadeIn | |
| 8 | + import androidx.compose.animation.fadeOut | |
| 9 | + import androidx.compose.animation.slideInVertically | |
| 10 | + import androidx.compose.animation.slideOutVertically | |
| 11 | + import androidx.compose.foundation.background | |
| 12 | + import androidx.compose.foundation.clickable | |
| 13 | + import androidx.compose.foundation.gestures.detectHorizontalDragGestures | |
| 14 | + import androidx.compose.foundation.layout.Arrangement | |
| 15 | + import androidx.compose.foundation.layout.Box | |
| 16 | + import androidx.compose.foundation.layout.BoxWithConstraints | |
| 17 | + import androidx.compose.foundation.layout.Row | |
| 18 | + import androidx.compose.foundation.layout.WindowInsets | |
| 19 | + import androidx.compose.foundation.layout.fillMaxHeight | |
| 20 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 21 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 22 | + import androidx.compose.foundation.layout.navigationBarsPadding | |
| 23 | + import androidx.compose.foundation.layout.offset | |
| 24 | + import androidx.compose.foundation.layout.padding | |
| 25 | + import androidx.compose.foundation.layout.safeDrawing | |
| 26 | + import androidx.compose.foundation.layout.width | |
| 27 | + import androidx.compose.foundation.layout.windowInsetsPadding | |
| 28 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 29 | + import androidx.compose.runtime.Composable | |
| 30 | + import androidx.compose.runtime.LaunchedEffect | |
| 31 | + import androidx.compose.runtime.getValue | |
| 32 | + import androidx.compose.ui.Alignment | |
| 33 | + import androidx.compose.ui.Modifier | |
| 34 | + import androidx.compose.ui.draw.alpha | |
| 35 | + import androidx.compose.ui.draw.clip | |
| 36 | + import androidx.compose.ui.graphics.Color | |
| 37 | + import androidx.compose.ui.input.pointer.pointerInput | |
| 38 | + import androidx.compose.ui.unit.dp | |
| 39 | + import androidx.compose.ui.unit.sp | |
| 40 | + import cc.vectrum.code.data.AppState | |
| 41 | + import cc.vectrum.code.theme.Cap | |
| 42 | + import cc.vectrum.code.theme.CapKind | |
| 43 | + import cc.vectrum.code.theme.CapSize | |
| 44 | + import cc.vectrum.code.theme.Txt | |
| 45 | + import cc.vectrum.code.theme.menu | |
| 46 | + import cc.vectrum.code.theme.palette | |
| 47 | + import cc.vectrum.code.theme.plate | |
| 48 | + import cc.vectrum.code.ui.chat.ThreadScreen | |
| 49 | + import cc.vectrum.code.ui.composer.NewThreadScreen | |
| 50 | + import cc.vectrum.code.ui.diff.DiffScreen | |
| 51 | + import cc.vectrum.code.ui.pair.PairScreen | |
| 52 | + import cc.vectrum.code.ui.panels.CommandPalette | |
| 53 | + import cc.vectrum.code.ui.panels.RightPanel | |
| 54 | + import cc.vectrum.code.ui.settings.SettingsScreen | |
| 55 | + import cc.vectrum.code.ui.sidebar.Sidebar | |
| 56 | + import kotlinx.coroutines.delay | |
| 57 | + | |
| 58 | + /** The desktop's sheet inset and corner radius. */ | |
| 59 | + val SheetInset = 8.dp | |
| 60 | + val SheetRadius = 24.dp | |
| 61 | + val SidebarWidth = 280.dp | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * The window, as the desktop lays it out: the sidebar on the page, and the | |
| 65 | + * conversation on a raised sheet beside it. On a phone the sidebar is a | |
| 66 | + * drawer that slides over the sheet; on a tablet it sits beside it. | |
| 67 | + */ | |
| 68 | + @Composable | |
| 69 | + fun Root(app: AppState, nav: Nav) { | |
| 70 | + val p = palette | |
| 71 | + BackHandler(enabled = nav.canGoBack) { nav.back() } | |
| 72 | + BoxWithConstraints( | |
| 73 | + Modifier | |
| 74 | + .fillMaxSize() | |
| 75 | + .background(p.page) | |
| 76 | + .windowInsetsPadding(WindowInsets.safeDrawing), | |
| 77 | + ) { | |
| 78 | + val wide = maxWidth >= 840.dp | |
| 79 | + val drawerWidth = minOf(SidebarWidth + 24.dp, maxWidth - 48.dp) | |
| 80 | + if (wide) { | |
| 81 | + Row(Modifier.fillMaxSize()) { | |
| 82 | + Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight()) | |
| 83 | + Sheet(app, nav, Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset)) | |
| 84 | + } | |
| 85 | + } else { | |
| 86 | + val shift by animateDpAsState(if (nav.sidebarOpen) drawerWidth else 0.dp, label = "drawer") | |
| 87 | + val scrim by animateFloatAsState(if (nav.sidebarOpen) 1f else 0f, label = "scrim") | |
| 88 | + Box( | |
| 89 | + Modifier | |
| 90 | + .fillMaxSize() | |
| 91 | + .pointerInput(Unit) { | |
| 92 | + // A swipe from the left edge opens the sidebar, as on most phone apps. | |
| 93 | + var start = 0f | |
| 94 | + var total = 0f | |
| 95 | + detectHorizontalDragGestures( | |
| 96 | + onDragStart = { start = it.x; total = 0f }, | |
| 97 | + onHorizontalDrag = { _, amount -> total += amount }, | |
| 98 | + onDragEnd = { | |
| 99 | + if (start < 32.dp.toPx() && total > 60.dp.toPx()) nav.sidebarOpen = true | |
| 100 | + if (nav.sidebarOpen && total < -60.dp.toPx()) nav.sidebarOpen = false | |
| 101 | + }, | |
| 102 | + ) | |
| 103 | + }, | |
| 104 | + ) { | |
| 105 | + Sidebar(app, nav, Modifier.width(drawerWidth).fillMaxHeight().offset(x = shift - drawerWidth)) | |
| 106 | + Box(Modifier.fillMaxSize().offset(x = shift)) { | |
| 107 | + Sheet(app, nav, Modifier.fillMaxSize().padding(SheetInset)) | |
| 108 | + if (scrim > 0f) { | |
| 109 | + Box( | |
| 110 | + Modifier | |
| 111 | + .fillMaxSize() | |
| 112 | + .alpha(scrim) | |
| 113 | + .background(Color.Black.copy(alpha = if (p.dark) 0.3f else 0.08f)) | |
| 114 | + .clickable(interactionSource = null, indication = null) { nav.sidebarOpen = false }, | |
| 115 | + ) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + } | |
| 120 | + if (nav.palette) CommandPalette(app, nav) | |
| 121 | + NoticeBar(app, Modifier.align(Alignment.BottomCenter)) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** The raised sheet and what's on it. */ | |
| 126 | + @Composable | |
| 127 | + private fun Sheet(app: AppState, nav: Nav, modifier: Modifier) { | |
| 128 | + val p = palette | |
| 129 | + val shape = RoundedCornerShape(SheetRadius) | |
| 130 | + Box(modifier.plate(p, shape).clip(shape)) { | |
| 131 | + when (val screen = nav.screen) { | |
| 132 | + Screen.Home -> if (app.hosts.isEmpty()) PairScreen(app, nav) else NewThreadScreen(app, nav, Screen.NewThread()) | |
| 133 | + is Screen.Thread -> { | |
| 134 | + val host = app.host(screen.ref.host) | |
| 135 | + if (host == null) { | |
| 136 | + LaunchedEffect(screen) { nav.home() } | |
| 137 | + } else { | |
| 138 | + ThreadScreen(app, nav, host, screen.ref.thread) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + is Screen.NewThread -> NewThreadScreen(app, nav, screen) | |
| 142 | + is Screen.Settings -> SettingsScreen(app, nav, screen.page) | |
| 143 | + Screen.Pair -> PairScreen(app, nav) | |
| 144 | + is Screen.Diff -> DiffScreen(app, nav, screen) | |
| 145 | + } | |
| 146 | + if (nav.surface != null || nav.surfacePicker) { | |
| 147 | + val screen = nav.screen | |
| 148 | + if (screen is Screen.Thread) RightPanel(app, nav, screen.ref) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + /** The desktop's notices: "Settled 1 thread, Ctrl+Z to undo", errors from the host. */ | |
| 154 | + @Composable | |
| 155 | + private fun NoticeBar(app: AppState, modifier: Modifier) { | |
| 156 | + val p = palette | |
| 157 | + val notice = app.notice | |
| 158 | + LaunchedEffect(notice) { | |
| 159 | + if (notice != null) { | |
| 160 | + delay(if (notice.undo != null) 5_000 else 4_000) | |
| 161 | + app.clearNotice(notice) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + AnimatedVisibility( | |
| 165 | + visible = notice != null, | |
| 166 | + modifier = modifier.navigationBarsPadding().padding(bottom = 96.dp, start = 16.dp, end = 16.dp), | |
| 167 | + enter = fadeIn() + slideInVertically { it / 2 }, | |
| 168 | + exit = fadeOut() + slideOutVertically { it / 2 }, | |
| 169 | + ) { | |
| 170 | + val shown = notice ?: return@AnimatedVisibility | |
| 171 | + Row( | |
| 172 | + Modifier.menu(p, RoundedCornerShape(14.dp)).padding(start = 14.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), | |
| 173 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 174 | + verticalAlignment = Alignment.CenterVertically, | |
| 175 | + ) { | |
| 176 | + Txt( | |
| 177 | + shown.text, | |
| 178 | + Modifier.weight(1f, fill = false).padding(vertical = 6.dp), | |
| 179 | + color = if (shown.error) p.red else p.ink, | |
| 180 | + size = 13.sp, | |
| 181 | + maxLines = 3, | |
| 182 | + ) | |
| 183 | + val undo = shown.undo | |
| 184 | + if (undo != null) { | |
| 185 | + Cap(label = "Undo", size = CapSize.Compact, kind = CapKind.Standard) { | |
| 186 | + undo() | |
| 187 | + app.clearNotice(shown) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + } | |
| 192 | + } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved(thread.summary.title) } | |
| 19 | + } | |
| @@ -0,0 +1,34 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.Column | |
| 5 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 6 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 7 | + import androidx.compose.runtime.Composable | |
| 8 | + import androidx.compose.runtime.DisposableEffect | |
| 9 | + import androidx.compose.ui.Modifier | |
| 10 | + import cc.vectrum.code.data.AppState | |
| 11 | + import cc.vectrum.code.data.HostState | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.composer.Composer | |
| 14 | + import cc.vectrum.code.ui.composer.Dock | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * An open thread on the sheet: the chat header, the transcript, what the | |
| 18 | + * agent waits on docked over the composer, and the composer. | |
| 19 | + */ | |
| 20 | + @Composable | |
| 21 | + fun ThreadScreen(app: AppState, nav: Nav, host: HostState, id: String) { | |
| 22 | + val thread = host.open(id) | |
| 23 | + DisposableEffect(host, id) { | |
| 24 | + onDispose { host.close(id) } | |
| 25 | + } | |
| 26 | + Column(Modifier.fillMaxSize()) { | |
| 27 | + ChatHeader(app, nav, thread) | |
| 28 | + Box(Modifier.weight(1f).fillMaxWidth()) { | |
| 29 | + Timeline(app, nav, thread, Modifier.fillMaxSize()) | |
| 30 | + } | |
| 31 | + Dock(app, nav, thread) | |
| 32 | + Composer(app, nav, thread) | |
| 33 | + } | |
| 34 | + } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun Timeline(app: AppState, nav: Nav, thread: OpenThread, modifier: Modifier = Modifier) { | |
| 18 | + Box(modifier.padding(16.dp)) { Engraved("${thread.items.size} items") } | |
| 19 | + } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.composer | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun Composer(app: AppState, nav: Nav, thread: OpenThread) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved("Composer") } | |
| 19 | + } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | + package cc.vectrum.code.ui.composer | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun Dock(app: AppState, nav: Nav, thread: OpenThread) {} | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.composer | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved("New thread") } | |
| 19 | + } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.diff | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved("Diff") } | |
| 19 | + } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.pair | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun PairScreen(app: AppState, nav: Nav) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved("Pair") } | |
| 19 | + } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | + package cc.vectrum.code.ui.panels | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun CommandPalette(app: AppState, nav: Nav) {} | |
| @@ -0,0 +1,17 @@ | ||
| 1 | + package cc.vectrum.code.ui.panels | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {} | |
| @@ -0,0 +1,19 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Box | |
| 4 | + import androidx.compose.foundation.layout.padding | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.ui.Modifier | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.data.OpenThread | |
| 10 | + import cc.vectrum.code.data.ThreadRef | |
| 11 | + import cc.vectrum.code.theme.Engraved | |
| 12 | + import cc.vectrum.code.ui.Nav | |
| 13 | + import cc.vectrum.code.ui.Screen | |
| 14 | + | |
| 15 | + // Placeholder until it's built. | |
| 16 | + @Composable | |
| 17 | + fun SettingsScreen(app: AppState, nav: Nav, page: String) { | |
| 18 | + Box(Modifier.padding(16.dp)) { Engraved("Settings") } | |
| 19 | + } | |
| @@ -0,0 +1,48 @@ | ||
| 1 | + package cc.vectrum.code.util | |
| 2 | + | |
| 3 | + /** A project's badge letters, as the desktop picks them: `vectrum.git` → `VG`, `codey` → `CO`. */ | |
| 4 | + fun initials(name: String): String { | |
| 5 | + val words = name.split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() } | |
| 6 | + val letters = when { | |
| 7 | + words.size >= 2 && words[1].first().isLetter() -> "${words[0].first()}${words[1].first()}" | |
| 8 | + words.isNotEmpty() -> words[0].take(2) | |
| 9 | + else -> "?" | |
| 10 | + } | |
| 11 | + return letters.uppercase() | |
| 12 | + } | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * The tint a project's badge takes. The desktop hashes the name with Rust's | |
| 16 | + * `DefaultHasher` (SipHash-1-3 with zero keys, `str` hashed as its bytes and a | |
| 17 | + * 0xff) and takes it modulo the six tints, so this does the same. | |
| 18 | + */ | |
| 19 | + fun tintFor(name: String): Int = java.lang.Long.remainderUnsigned(sipHash13(name.toByteArray() + 0xff.toByte()), 6).toInt() | |
| 20 | + | |
| 21 | + private fun sipHash13(data: ByteArray): Long { | |
| 22 | + var v0 = 0x736f6d6570736575L | |
| 23 | + var v1 = 0x646f72616e646f6dL | |
| 24 | + var v2 = 0x6c7967656e657261L | |
| 25 | + var v3 = 0x7465646279746573L | |
| 26 | + fun round() { | |
| 27 | + v0 += v1; v1 = java.lang.Long.rotateLeft(v1, 13); v1 = v1 xor v0; v0 = java.lang.Long.rotateLeft(v0, 32) | |
| 28 | + v2 += v3; v3 = java.lang.Long.rotateLeft(v3, 16); v3 = v3 xor v2 | |
| 29 | + v0 += v3; v3 = java.lang.Long.rotateLeft(v3, 21); v3 = v3 xor v0 | |
| 30 | + v2 += v1; v1 = java.lang.Long.rotateLeft(v1, 17); v1 = v1 xor v2; v2 = java.lang.Long.rotateLeft(v2, 32) | |
| 31 | + } | |
| 32 | + val whole = data.size / 8 * 8 | |
| 33 | + for (offset in 0 until whole step 8) { | |
| 34 | + var m = 0L | |
| 35 | + for (ix in 7 downTo 0) m = (m shl 8) or (data[offset + ix].toLong() and 0xff) | |
| 36 | + v3 = v3 xor m | |
| 37 | + round() | |
| 38 | + v0 = v0 xor m | |
| 39 | + } | |
| 40 | + var last = (data.size.toLong() and 0xff) shl 56 | |
| 41 | + for (ix in data.size - 1 downTo whole) last = last or ((data[ix].toLong() and 0xff) shl (8 * (ix - whole))) | |
| 42 | + v3 = v3 xor last | |
| 43 | + round() | |
| 44 | + v0 = v0 xor last | |
| 45 | + v2 = v2 xor 0xff | |
| 46 | + repeat(3) { round() } | |
| 47 | + return v0 xor v1 xor v2 xor v3 | |
| 48 | + } | |
| @@ -0,0 +1,9 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="108dp" android:height="108dp" | |
| 4 | + android:viewportWidth="108" android:viewportHeight="108"> | |
| 5 | + <group android:translateX="23.6" android:translateY="23.6" android:scaleX="1.9" android:scaleY="1.9"> | |
| 6 | + <path android:fillColor="#FFDCDCDC" android:fillType="evenOdd" | |
| 7 | + android:pathData="M2 13a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0-17 0ZM11.54 7.69Q10 4 14 4H26Q30 4 28.46 7.69L21.54 24.31Q20 28 18.46 24.31Z" /> | |
| 8 | + </group> | |
| 9 | + </vector> | |
| @@ -0,0 +1,11 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M4,13a8,8 0 1 0 16,0a8,8 0 1 0 -16,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M12 9v4l2 2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M5 3 2 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m22 6-3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M6.38 18.7 4 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M17.64 18.67 20 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + </vector> | |
| @@ -0,0 +1,11 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M6.87 6.87a8 8 0 1 0 11.26 11.26" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M19.9 14.25a8 8 0 0 0-9.15-9.15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="m22 6-3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M6.26 18.67 4 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="m2 2 20 20" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M4 4 2 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M10 12h4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,10 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M4 8v11a2 2 0 0 0 2 2h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M20 8v11a2 2 0 0 1-2 2h-2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m9 15 3-3 3 3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M12 12v9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + </vector> | |
| @@ -0,0 +1,9 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="m9.5 17 5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m9.5 12 5 5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 5v14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m19 12-7 7-7-7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 17V3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m6 11 6 6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M19 21H5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m12 19-7-7 7-7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M19 12H5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M5 12h14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m12 5 7 7-7 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m5 12 7-7 7 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M12 19V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 5v14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m18.065 8.496-12.125 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="m5.94 8.504 12.125 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M8,12a4,4 0 1 0 8,0a4,4 0 1 0 -8,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M10.268 21a2 2 0 0 0 3.464 0" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M15,2h6a1,1 0 0 1 1,1v6a1,1 0 0 1 -1,1h-6a1,1 0 0 1 -1,-1v-6a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 5v16" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,11 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 8V4H8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M6,8h12a2,2 0 0 1 2,2v8a2,2 0 0 1 -2,2h-12a2,2 0 0 1 -2,-2v-8a2,2 0 0 1 2,-2Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M2 14h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M20 14h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M15 13v2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M9 13v2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + </vector> | |
| @@ -0,0 +1,13 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 18V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M17.997 5.125a4 4 0 0 1 2.526 5.77" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M18 18a4 4 0 0 0 2-7.464" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + <path android:pathData="M6 18a4 4 0 0 1-2-7.464" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 12 | + <path android:pathData="M6.003 5.125a4 4 0 0 0-2.526 5.77" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 13 | + </vector> | |
| @@ -0,0 +1,16 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M12 20v-9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M14.12 3.88 16 2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M21 21a4 4 0 0 0-3.81-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M21 5a4 4 0 0 1-3.55 3.97" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M22 13h-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + <path android:pathData="M3 21a4 4 0 0 1 3.81-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 12 | + <path android:pathData="M3 5a4 4 0 0 0 3.55 3.97" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 13 | + <path android:pathData="M6 13H2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 14 | + <path android:pathData="m8 2 1.88 1.88" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 15 | + <path android:pathData="M9 7.13V6a3 3 0 1 1 6 0v1.13" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 16 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M9,13a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M5 21v-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M12 21V3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M19 21V9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M20 6 9 17l-5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + </vector> | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m6 9 6 6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + </vector> | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m15 18-6-6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + </vector> | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m9 18 6-6-6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + </vector> | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m18 15-6-6-6 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + </vector> | |
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.
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.