diff --git a/apps/android/.gitignore b/apps/android/.gitignore new file mode 100644 index 0000000000..f0fe0c132b --- /dev/null +++ b/apps/android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +local.properties +/app/build/ +/build/ +*.iml +.idea/ diff --git a/apps/android/README.md b/apps/android/README.md new file mode 100644 index 0000000000..f29ee2f363 --- /dev/null +++ b/apps/android/README.md @@ -0,0 +1,64 @@ +# Zeron for Android + +A Jetpack Compose viewport onto the zeron mesh, built on the same Rust mobile +core as the iOS app (`crates/mobile`). **Rust decides what to paint and +where; Kotlin paints, scrolls and handles gestures.** The transcript's text +measurement, markdown layout, prefix-sum virtualization and display lists are +the exact code iOS runs — see [`docs/mobile-rewrite.md`](../../docs/mobile-rewrite.md). + +The UI is Material 3 Expressive (`MaterialExpressiveTheme`, expressive motion, +flexible app bars, shape-morphing loading indicators, connected button groups, +segmented lists) themed with Zeron's own palette and Geist type. + +## Build & run + +Requires JDK 21, the Android SDK (platform 37, NDK 29.0.14206865), and Rust +with `cargo install cargo-ndk` and +`rustup target add aarch64-linux-android x86_64-linux-android`. + +```sh +cd apps/android +./gradlew :app:installDebug +``` + +The `buildCore` task runs `scripts/android/build-core.sh`, which builds +`crates/mobile` for Android (`jniLibs`) and generates its Kotlin bindings into +`target/android-core/`. `-PzeronSkipCore` reuses the last build while +iterating on Kotlin; `ZERON_ANDROID_ABIS=arm64-v8a` builds one ABI. +`scripts/android/gen-icons.sh` rasterizes the shared tool/file SVG icons +(needs `rsvg-convert`). The Geist fonts are read straight from the iOS app's +`Fonts/` folder, so both platforms measure and draw the same bytes. + +## Layout + +``` +core/ AppModel (owns CoreClient, republishes snapshots as flows), + CredentialStore, Fonts + AndroidMeasurer (Minikin fallback + measurement for glyphs Geist lacks) +design/ ZeronTheme (Material 3 Expressive), transcript palette +transcript/ TranscriptState (layout engine + viewport: anchoring, follow the + tail), Transcript (virtualized rows over LayoutFrame), RowModel + (canvas painter for Rust display lists, streaming veil, fades), + Widgets (copy, disclosures, tool rail, shimmer, images…) +ui/ Sign-in, sessions, session + composer, new session, search, + settings +``` + +## Launch extras + +Mirrors the iOS launch arguments: + +```sh +adb shell am start -n sh.zeron.android/.MainActivity \ + --ez demo true --es route chat:chat-veil +``` + +| Extra | Effect | +| --- | --- | +| `--ez demo true` | Offline demo workspace (Rust `DemoHost`) | +| `--ez fast true` / `--ez longreply true` | Demo stream speed / reply length | +| `--ez big true` / `--ez huge true` | Demo transcripts with 120 / 600 turns | +| `--es route chat:` / `new` / `search` / `settings` | Open a screen at launch | +| `--ez signedout true` | Clear stored credentials | +| `--es wallpaper ` / `none` | Set (or clear) the wallpaper from a file the app can read, e.g. `adb push art.jpg /data/local/tmp/ && adb shell run-as sh.zeron.android cp /data/local/tmp/art.jpg files/` then `--es wallpaper /data/user/0/sh.zeron.android/files/art.jpg` | +| `--es wallpaper-effect ` | Wallpaper effect | diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000000..351d558c4e --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,108 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +// The Rust mobile core (crates/mobile) — the library the iOS app links — +// built for Android with its generated Kotlin bindings. `-PzeronSkipCore` +// reuses the last build while iterating on Kotlin only. +val NDK_VERSION = "29.0.14206865" +val repoRoot = rootProject.projectDir.resolve("../..").canonicalFile +val coreOut = repoRoot.resolve("target/android-core") +val iconsOut = layout.buildDirectory.dir("generated/zeron-icons") +val skipCore = providers.gradleProperty("zeronSkipCore").isPresent + +val buildCore by tasks.registering(Exec::class) { + description = "Builds crates/mobile for Android and generates Kotlin bindings." + val ndk = System.getenv("ANDROID_NDK_HOME") + ?: androidComponents.sdkComponents.sdkDirectory.get().dir("ndk/$NDK_VERSION").asFile.path + val lib = coreOut.resolve("jniLibs/arm64-v8a/libzeron_mobile.so") + val skip = skipCore + workingDir = repoRoot + commandLine("bash", "scripts/android/build-core.sh", coreOut.path) + environment("ANDROID_NDK_HOME", ndk) + onlyIf { !skip || !lib.exists() } +} + +// Tool and file icons: the iOS asset catalog's SVGs, rasterized. +val genIcons by tasks.registering(Exec::class) { + description = "Rasterizes the shared transcript icons." + val out = iconsOut.get().asFile + inputs.dir(repoRoot.resolve("apps/ios/Zeron/Assets.xcassets")) + inputs.dir(repoRoot.resolve("crates/ui/assets/icons")) + inputs.file(repoRoot.resolve("scripts/android/svg2vd.py")) + outputs.dir(out) + commandLine("bash", repoRoot.resolve("scripts/android/gen-icons.sh").path, out.resolve("assets/icons").path, out.resolve("res").path) +} + +android { + namespace = "sh.zeron.android" + compileSdk = 37 + ndkVersion = NDK_VERSION + + defaultConfig { + applicationId = "sh.zeron.android" + minSdk = 29 + targetSdk = 37 + versionCode = 1 + versionName = "0.2.97" + ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { compose = true } + + sourceSets["main"].apply { + kotlin.directories.add(coreOut.resolve("kotlin").path) + jniLibs.directories.add(coreOut.resolve("jniLibs").path) + // The exact font bytes the Rust layout engine measures. + assets.directories.add(repoRoot.resolve("apps/ios/Zeron/Fonts").path) + assets.directories.add(iconsOut.get().asFile.resolve("assets").path) + res.directories.add(iconsOut.get().asFile.resolve("res").path) + } + + packaging { jniLibs { useLegacyPackaging = false } } +} + +tasks.named("preBuild") { dependsOn(buildCore, genIcons) } + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.browser) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.kotlinx.coroutines.android) + implementation("${libs.jna.get()}@aar") + debugImplementation(libs.compose.ui.tooling) +} + +kotlin { + compilerOptions { + optIn.addAll( + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", + ) + } +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..5ac9592456 --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/java/sh/zeron/android/MainActivity.kt b/apps/android/app/src/main/java/sh/zeron/android/MainActivity.kt new file mode 100644 index 0000000000..869f99d762 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/MainActivity.kt @@ -0,0 +1,59 @@ +package sh.zeron.android + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import sh.zeron.android.core.LaunchOptions +import sh.zeron.android.ui.ZeronRoot + +/** + * Launch extras mirror the iOS launch arguments, e.g. + * `adb shell am start -n sh.zeron.android/.MainActivity --ez demo true --es route chat:chat-veil` + * (`--es wallpaper ` sets the wallpaper from a file the app can read). + */ +class MainActivity : ComponentActivity() { + private val model get() = (application as ZeronApplication).model + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + val extras = intent.extras + model.boot( + LaunchOptions( + demo = extras?.getBoolean("demo") == true, + fast = extras?.getBoolean("fast") == true, + longReply = extras?.getBoolean("longreply") == true, + big = extras?.getBoolean("big") == true, + huge = extras?.getBoolean("huge") == true, + noProjects = extras?.getBoolean("noprojects") == true, + signedOut = extras?.getBoolean("signedout") == true, + route = extras?.getString("route"), + wallpaper = extras?.getString("wallpaper"), + wallpaperEffect = extras?.getString("wallpaper-effect"), + ), + ) + handleCallback(intent) + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) = model.onForeground() + override fun onStop(owner: LifecycleOwner) = model.onBackground() + }) + setContent { ZeronRoot(model) } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleCallback(intent) + } + + private fun handleCallback(intent: Intent?) { + val data = intent?.data ?: return + if (data.scheme == "zeron") model.handleCallback(data.toString()) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ZeronApplication.kt b/apps/android/app/src/main/java/sh/zeron/android/ZeronApplication.kt new file mode 100644 index 0000000000..e4604293c3 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ZeronApplication.kt @@ -0,0 +1,18 @@ +package sh.zeron.android + +import android.app.Application +import sh.zeron.android.core.AppModel +import sh.zeron.android.core.Fonts +import sh.zeron.android.design.LocalAssets + +class ZeronApplication : Application() { + lateinit var model: AppModel + private set + + override fun onCreate() { + super.onCreate() + LocalAssets.manager = assets + Fonts.init(assets) + model = AppModel(this) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/core/AppModel.kt b/apps/android/app/src/main/java/sh/zeron/android/core/AppModel.kt new file mode 100644 index 0000000000..33713f800c --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/core/AppModel.kt @@ -0,0 +1,421 @@ +package sh.zeron.android.core + +import android.app.Application +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import sh.zeron.android.design.Appearance +import sh.zeron.android.design.ThemeMode +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import uniffi.zeron_core.AuthCallback +import uniffi.zeron_core.AuthOrg +import uniffi.zeron_core.ChatConfig +import uniffi.zeron_core.ClientEvent +import uniffi.zeron_core.ClientListener +import uniffi.zeron_core.Connectivity +import uniffi.zeron_core.CoreClient +import uniffi.zeron_core.CoreConfig +import uniffi.zeron_core.Credentials +import uniffi.zeron_core.DemoFixture +import uniffi.zeron_core.DemoOptions +import uniffi.zeron_core.NewSession +import uniffi.zeron_core.SandboxLevel +import uniffi.zeron_core.SendRequest +import uniffi.zeron_core.BusyPolicy +import uniffi.zeron_core.SessionRow +import uniffi.zeron_core.SessionTarget +import uniffi.zeron_core.StreamSpeed +import uniffi.zeron_core.TranscriptScale +import uniffi.zeron_core.WorkspaceSnapshot +import uniffi.zeron_core.WorktreeSpec +import uniffi.zeron_core.authExchangeCode +import uniffi.zeron_core.authListOrgs +import uniffi.zeron_core.authProductionEdgeUrl +import uniffi.zeron_core.authRefresh +import uniffi.zeron_core.parseAuthCallback +import uniffi.zeron_core.workosAuthorizeUrl +import java.io.File +import java.util.UUID + +/** How the app was launched (adb extras mirror the iOS launch arguments). */ +data class LaunchOptions( + val demo: Boolean = false, + val fast: Boolean = false, + val longReply: Boolean = false, + val big: Boolean = false, + val huge: Boolean = false, + val noProjects: Boolean = false, + val signedOut: Boolean = false, + val route: String? = null, + val wallpaper: String? = null, + val wallpaperEffect: String? = null, +) + +/** + * App-wide state owner: holds the Rust [CoreClient], republishes its + * snapshots as flows, and fans session events out to screens. Core events + * arrive on Rust threads and hop to the main thread here. + */ +class AppModel(private val app: Application) { + private val scope = MainScope() + private val main = Handler(Looper.getMainLooper()) + val credentials = CredentialStore(app) + val wallpaper = WallpaperStore(app) + + private val _client = MutableStateFlow(null) + val client: StateFlow = _client.asStateFlow() + + private val _workspace = MutableStateFlow(null) + val workspace: StateFlow = _workspace.asStateFlow() + + private val _connectivity = MutableStateFlow(null) + val connectivity: StateFlow = _connectivity.asStateFlow() + + /** Chat ids whose session or composer changed. */ + private val _sessionEvents = MutableSharedFlow(extraBufferCapacity = 256) + val sessionEvents: SharedFlow = _sessionEvents + + /** Organizations to pick from mid sign-in (more than one). */ + val orgChoice = MutableStateFlow, CompletableDeferred>?>(null) + val signInError = MutableStateFlow(null) + + private val settings = app.getSharedPreferences("settings", 0) + private val _appearance = MutableStateFlow( + Appearance( + runCatching { ThemeMode.valueOf(settings.getString("theme", "System")!!) }.getOrDefault(ThemeMode.System), + settings.getBoolean("dynamicColor", false), + ), + ) + val appearance: StateFlow = _appearance.asStateFlow() + + fun setAppearance(value: Appearance) { + _appearance.value = value + settings.edit().putString("theme", value.mode.name).putBoolean("dynamicColor", value.dynamicColor).apply() + } + + var lastDraft: NewSessionDraft = NewSessionDraft( + projectId = settings.getString("draft.project", null), + hostId = settings.getString("draft.host", null), + harness = settings.getString("draft.harness", null) ?: "claude-code", + model = settings.getString("draft.model", null), + effort = settings.getString("draft.effort", null), + ) + set(value) { + field = value + if (!launch.demo) { + settings.edit() + .putString("draft.project", value.projectId) + .putString("draft.host", value.hostId) + .putString("draft.harness", value.harness) + .putString("draft.model", value.model) + .putString("draft.effort", value.effort) + .apply() + } + } + + var launch = LaunchOptions() + private set + + val isDemo: Boolean get() = _client.value?.isDemo() == true + private var refreshScheduled = false + private var authState: String? = null + + private val listener = object : ClientListener { + override fun onEvent(event: ClientEvent) { + main.post { handle(event) } + } + } + + fun boot(options: LaunchOptions) { + launch = options + if (_client.value != null) return + if (options.signedOut) credentials.clear() + watchNetwork() + // `wallpaper ` / `wallpaper none` and `wallpaper-effect `: + // set the wallpaper at launch (screenshots, tests). + options.wallpaper?.let { path -> + if (path == "none") wallpaper.remove() else scope.launch { wallpaper.set(File(path)) } + } + options.wallpaperEffect?.let { name -> + WallpaperStore.effects.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let(wallpaper::setEffect) + } + val stored = credentials.stored() + when { + options.demo -> start(Credentials.Demo(demoOptions())) + stored != null -> start(stored) + } + // Relative times ("4m") and staleness age without events. + scope.launch { + while (true) { + delay(30_000) + refreshWorkspace() + } + } + } + + fun demoOptions() = DemoOptions( + fixture = if (launch.noProjects) DemoFixture.NO_PROJECTS else DemoFixture.STANDARD, + transcriptScale = when { + launch.huge -> TranscriptScale.Huge + launch.big -> TranscriptScale.Big + else -> TranscriptScale.Normal + }, + streamSpeed = if (launch.fast) StreamSpeed.FAST else StreamSpeed.REALISTIC, + longReply = launch.longReply, + ) + + private val coreDir get() = File(app.filesDir, "core") + + /** Local docs belong to one identity: another account starts empty. */ + private fun claimCoreDir(credentials: Credentials) { + val owner = when (credentials) { + is Credentials.WorkOs -> "${credentials.userId}/${credentials.orgId}" + is Credentials.Dev -> "${credentials.userId}/${credentials.orgId}" + is Credentials.Demo -> return + } + val marker = File(coreDir, ".owner") + if (runCatching { marker.readText() }.getOrNull() != owner) coreDir.deleteRecursively() + coreDir.mkdirs() + marker.writeText(owner) + } + + private fun start(credentials: Credentials): Boolean { + val demo = credentials is Credentials.Demo + val dir = if (demo) File(app.filesDir, "demo") else coreDir + if (!demo) claimCoreDir(credentials) + dir.mkdirs() + val config = CoreConfig( + edgeUrl = edgeUrl, + dataDir = dir.path, + deviceId = deviceId, + deviceName = deviceName(), + platform = "android", + appVersion = app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: "0", + ) + return try { + val client = CoreClient(config, credentials, listener) + if (credentials is Credentials.WorkOs) this.credentials.store(credentials) + _client.value = client + refreshWorkspace() + client.preloadSessions() + true + } catch (e: Exception) { + Log.e("Zeron", "core start failed", e) + false + } + } + + fun startDemo() { + start(Credentials.Demo(demoOptions())) + } + + val edgeUrl: String get() = authProductionEdgeUrl() + + private val deviceId: String + get() { + val prefs = app.getSharedPreferences("device", 0) + prefs.getString("deviceId", null)?.let { return it } + val id = "android-" + UUID.randomUUID().toString().take(8) + prefs.edit().putString("deviceId", id).apply() + return id + } + + private fun deviceName(): String = + Settings.Global.getString(app.contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL + + // ── sign-in ──────────────────────────────────────────────────────────── + + /** The WorkOS authorize URL to open in a Custom Tab. */ + fun beginSignIn(): String { + val state = UUID.randomUUID().toString() + authState = state + return workosAuthorizeUrl(state) + } + + /** `zeron://callback?code=…`: code → tokens → org → client. */ + fun handleCallback(url: String) { + when (val cb = parseAuthCallback(url)) { + is AuthCallback.Code -> scope.launch { signIn(cb.code) } + is AuthCallback.Error -> signInError.value = cb.description ?: cb.error + null -> Unit + } + } + + private suspend fun signIn(code: String) { + try { + val exchange = authExchangeCode(edgeUrl, code) + val orgs = authListOrgs(edgeUrl, exchange.tokens.accessToken) + if (orgs.isEmpty()) { + signInError.value = "This account isn't in an organization yet." + return + } + val org = if (orgs.size == 1) orgs[0] else { + val choice = CompletableDeferred() + orgChoice.value = orgs to choice + choice.await().also { orgChoice.value = null } ?: return + } + val tokens = authRefresh(edgeUrl, exchange.tokens.refreshToken, org.organizationId) + val user = exchange.user + val name = listOfNotNull(user.firstName, user.lastName).map { it.trim() }.filter { it.isNotEmpty() }.joinToString(" ") + credentials.profile = CredentialStore.Profile(name.ifEmpty { null }, user.email, org.name) + if (!start(Credentials.WorkOs(user.id, org.organizationId, tokens))) { + signInError.value = "Couldn't open your workspace. Try signing in again." + } + } catch (e: Exception) { + signInError.value = e.message ?: "Sign-in failed" + } + } + + fun signOut() { + val wasDemo = isDemo + _client.value?.shutdown() + _client.value = null + _workspace.value = null + _connectivity.value = null + if (!wasDemo) { + credentials.clear() + coreDir.deleteRecursively() + } + } + + val accountName: String + get() = when { + isDemo -> "Demo" + _client.value == null -> "Signed out" + else -> credentials.profile.let { it.name ?: it.email ?: "Signed in" } + } + + val accountDetail: String + get() = if (isDemo) "Offline demo workspace" else credentials.profile.let { p -> + listOfNotNull(if (p.name != null) p.email else null, p.orgName).joinToString(" · ").ifEmpty { "Zeron account" } + } + + // ── events ───────────────────────────────────────────────────────────── + + private fun handle(event: ClientEvent) { + when (event) { + is ClientEvent.WorkspaceChanged -> scheduleRefresh() + is ClientEvent.SessionChanged -> _sessionEvents.tryEmit(event.chatId) + is ClientEvent.ComposerChanged -> _sessionEvents.tryEmit(event.chatId) + is ClientEvent.ConnectivityChanged -> _connectivity.value = event.connectivity + is ClientEvent.AuthRefreshed -> credentials.updateTokens(event.tokens) + is ClientEvent.AuthExpired -> signOut() + } + } + + /** Coalesce bursts of registry frames into one rebuild per loop turn. */ + private fun scheduleRefresh() { + if (refreshScheduled) return + refreshScheduled = true + main.post { + refreshScheduled = false + refreshWorkspace() + } + } + + fun refreshWorkspace() { + val client = _client.value ?: return + _workspace.value = client.workspace() + } + + fun onForeground() { + _client.value?.onForeground() + refreshWorkspace() + } + + fun onBackground() { + _client.value?.onBackground() + } + + /** Pull to refresh: redial and re-probe, then settle for a beat. */ + suspend fun refresh() { + onForeground() + delay(700) + } + + private fun watchNetwork() { + val cm = app.getSystemService(ConnectivityManager::class.java) ?: return + cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + main.post { _client.value?.setNetworkOnline(true) } + } + + override fun onLost(network: Network) { + main.post { + val active = cm.getNetworkCapabilities(cm.activeNetwork) + val online = active?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + _client.value?.setNetworkOnline(online) + } + } + }) + } + + // ── writes ───────────────────────────────────────────────────────────── + + private inline fun attempt(what: String, body: () -> Unit) { + try { + body() + } catch (e: Exception) { + Log.w("Zeron", "$what failed", e) + } + } + + fun setPinned(id: String, pinned: Boolean) = attempt("pin") { + val c = _client.value ?: return + if (pinned) c.pinSession(id) else c.unpinSession(id) + } + + fun archive(id: String) = attempt("archive") { _client.value?.archiveSession(id) } + fun unarchive(id: String) = attempt("unarchive") { _client.value?.unarchiveSession(id) } + fun rename(id: String, title: String) = attempt("rename") { _client.value?.renameSession(id, title) } + fun setSectionCollapsed(id: String, collapsed: Boolean) = attempt("collapse") { _client.value?.setSectionCollapsed(id, collapsed) } + + fun row(id: String): SessionRow? = _client.value?.sessionRow(id) + + /** Create the chat and send its first message. */ + fun createSession(draft: NewSessionDraft, text: String, attachments: List = emptyList()): String? { + val client = _client.value ?: return null + val target = when { + draft.projectId != null -> SessionTarget.Project(draft.projectId) + draft.hostId != null -> SessionTarget.Projectless(draft.hostId) + else -> return null + } + val config = ChatConfig(draft.harness, draft.model, draft.effort, emptyMap(), SandboxLevel.WORKSPACE_WRITE) + return try { + val chatId = client.createSession(NewSession(target, config, if (draft.worktree) null else draft.branch, null, null)) + val handle = client.openSession(chatId) + val project = _workspace.value?.projects?.firstOrNull { it.id == draft.projectId } + val worktree = if (draft.worktree && project != null) WorktreeSpec(project.path, draft.branch ?: "HEAD", project.id) else null + handle.send(SendRequest(text, attachments, worktree, BusyPolicy.QUEUE)) + refreshWorkspace() + chatId + } catch (e: Exception) { + Log.w("Zeron", "create session failed", e) + null + } + } +} + +/** The new-session page's options (kept across launches). */ +data class NewSessionDraft( + val projectId: String? = null, + val hostId: String? = null, + val harness: String = "claude-code", + val model: String? = null, + val effort: String? = null, + val branch: String? = null, + val worktree: Boolean = false, +) diff --git a/apps/android/app/src/main/java/sh/zeron/android/core/CredentialStore.kt b/apps/android/app/src/main/java/sh/zeron/android/core/CredentialStore.kt new file mode 100644 index 0000000000..f59a81d24e --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/core/CredentialStore.kt @@ -0,0 +1,58 @@ +package sh.zeron.android.core + +import android.content.Context +import org.json.JSONObject +import uniffi.zeron_core.AuthTokens +import uniffi.zeron_core.Credentials + +/** + * Credentials and the signed-in account's display identity, in app-private + * storage (the client itself only knows ids). + */ +class CredentialStore(context: Context) { + private val prefs = context.getSharedPreferences("credentials", Context.MODE_PRIVATE) + + fun stored(): Credentials? { + val json = prefs.getString("credentials", null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + return when (json.optString("kind")) { + "workos" -> Credentials.WorkOs( + json.getString("userId"), + json.getString("orgId"), + AuthTokens(json.getString("access"), json.getString("refresh")), + ) + "dev" -> Credentials.Dev(json.getString("userId"), json.getString("orgId")) + else -> null + } + } + + fun store(credentials: Credentials) { + val json = when (credentials) { + is Credentials.WorkOs -> JSONObject() + .put("kind", "workos") + .put("userId", credentials.userId) + .put("orgId", credentials.orgId) + .put("access", credentials.tokens.accessToken) + .put("refresh", credentials.tokens.refreshToken) + is Credentials.Dev -> JSONObject().put("kind", "dev").put("userId", credentials.userId).put("orgId", credentials.orgId) + is Credentials.Demo -> return + } + prefs.edit().putString("credentials", json.toString()).apply() + } + + fun updateTokens(tokens: AuthTokens) { + val current = stored() as? Credentials.WorkOs ?: return + store(Credentials.WorkOs(current.userId, current.orgId, tokens)) + } + + fun clear() { + prefs.edit().clear().apply() + } + + data class Profile(val name: String? = null, val email: String? = null, val orgName: String? = null) + + var profile: Profile + get() = Profile(prefs.getString("name", null), prefs.getString("email", null), prefs.getString("orgName", null)) + set(value) { + prefs.edit().putString("name", value.name).putString("email", value.email).putString("orgName", value.orgName).apply() + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/core/Fonts.kt b/apps/android/app/src/main/java/sh/zeron/android/core/Fonts.kt new file mode 100644 index 0000000000..88b24f76b1 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/core/Fonts.kt @@ -0,0 +1,95 @@ +package sh.zeron.android.core + +import android.content.res.AssetManager +import android.graphics.Paint +import android.graphics.Typeface +import uniffi.zeron_core.FaceData +import uniffi.zeron_core.FaceRole +import uniffi.zeron_core.PlatformMeasurer +import uniffi.zeron_core.TextSystem + +/** + * The bundled faces. Rust measures the exact bytes Skia draws with (the same + * files the iOS app registers with CoreText), so measurement and rendering + * share one source of truth. + */ +object Fonts { + val files: List> = listOf( + FaceRole.SANS to "Geist.ttf", + FaceRole.SANS_MEDIUM to "Geist-Medium.ttf", + FaceRole.SANS_SEMIBOLD to "Geist-SemiBold.ttf", + FaceRole.SANS_BOLD to "Geist-Bold.ttf", + FaceRole.SANS_ITALIC to "Geist-Italic.ttf", + FaceRole.SANS_MEDIUM_ITALIC to "Geist-MediumItalic.ttf", + FaceRole.SANS_SEMIBOLD_ITALIC to "Geist-SemiBoldItalic.ttf", + FaceRole.SANS_BOLD_ITALIC to "Geist-BoldItalic.ttf", + FaceRole.MONO to "GeistMono.ttf", + FaceRole.MONO_MEDIUM to "GeistMono-Medium.ttf", + FaceRole.MONO_SEMIBOLD to "GeistMono-SemiBold.ttf", + FaceRole.MONO_ITALIC to "GeistMono-Italic.ttf", + ) + + private lateinit var faces: List + private val typefaces = HashMap() + + fun init(assets: AssetManager) { + if (::faces.isInitialized) return + faces = files.mapNotNull { (role, file) -> + runCatching { + val bytes = assets.open(file).use { it.readBytes() } + typefaces[role] = Typeface.createFromAsset(assets, file) + FaceData(role, bytes) + }.getOrNull() + } + } + + val faceData: List get() = faces + + fun typeface(face: FaceRole): Typeface = typefaces[face] ?: typefaces[FaceRole.SANS] ?: Typeface.DEFAULT + + /** A paint for `face` at `size` px. Linear metrics: advances match Rust's unhinted ones. */ + fun paint(face: FaceRole, size: Float, ligatures: Boolean): Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = typeface(face) + textSize = size + isSubpixelText = true + isLinearText = true + if (!ligatures) fontFeatureSettings = "'liga' 0, 'calt' 0" + } +} + +/** Skia (via Minikin) as ground truth for glyphs the bundled faces don't cover. */ +class AndroidMeasurer : PlatformMeasurer { + private data class Key(val face: FaceRole, val centi: Int, val ligatures: Boolean) + + // Called on the Rust layout thread; Paint isn't thread-safe. + private val paints = ThreadLocal.withInitial { HashMap() } + + private fun paint(face: FaceRole, size: Float, ligatures: Boolean): Paint = + paints.get()!!.getOrPut(Key(face, (size * 100).toInt(), ligatures)) { Fonts.paint(face, size, ligatures) } + + override fun measure(face: FaceRole, size: Float, ligatures: Boolean, text: String): Float = + paint(face, size, ligatures).measureText(text) + + /** Per-scalar advances of `text` laid out as one run, folded from UTF-16 units. */ + override fun measureRun(face: FaceRole, size: Float, ligatures: Boolean, text: String): List { + val n = text.length + if (n == 0) return emptyList() + val perUnit = FloatArray(n) + paint(face, size, ligatures).getTextRunAdvances(text.toCharArray(), 0, n, 0, n, false, perUnit, 0) + val out = ArrayList(text.codePointCount(0, n)) + var i = 0 + while (i < n) { + val w = Character.charCount(text.codePointAt(i)) + var sum = 0f + for (k in i until minOf(n, i + w)) sum += perUnit[k] + out.add(sum) + i += w + } + return out + } +} + +/** One process-wide text system shared by every transcript. */ +object TextEngine { + val shared: TextSystem by lazy { TextSystem(Fonts.faceData, AndroidMeasurer()) } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/core/Wallpaper.kt b/apps/android/app/src/main/java/sh/zeron/android/core/Wallpaper.kt new file mode 100644 index 0000000000..0d8d2ddb30 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/core/Wallpaper.kt @@ -0,0 +1,146 @@ +package sh.zeron.android.core + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import uniffi.zeron_core.WallpaperEffect +import uniffi.zeron_core.wallpaperRender +import uniffi.zeron_core.wallpaperSafeOpacity +import java.io.File +import java.nio.ByteBuffer +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * The chat wallpaper (desktop "new thread background"): one image plus an + * effect, stored locally. Effects and the contrast guard run in the Rust + * core (`wallpaper.rs`) off the main thread; renders are cached per + * appearance — the same pipeline as iOS. + */ +class WallpaperStore(private val context: Context) { + data class State(val set: Boolean, val name: String?, val effect: WallpaperEffect, val generation: Int) + + /** A rendered wallpaper and the highest opacity that keeps text readable. */ + class Render(val image: ImageBitmap, val opacity: Float) + + private val prefs = context.getSharedPreferences("wallpaper", 0) + private val dir = File(context.filesDir, "wallpaper").apply { mkdirs() } + private val file = File(dir, "wallpaper.jpg") + private val cache = HashMap() + + private val _state = MutableStateFlow(read(0)) + val state: StateFlow = _state.asStateFlow() + + private fun read(generation: Int) = State( + set = file.exists(), + name = prefs.getString("name", null), + effect = runCatching { WallpaperEffect.valueOf(prefs.getString("effect", "NONE")!!) }.getOrDefault(WallpaperEffect.NONE), + generation = generation, + ) + + private fun changed() { + synchronized(cache) { cache.clear() } + _state.value = read(_state.value.generation + 1) + } + + /** Store a picked image (long side ≤ 1600 px, decoded downsampled). */ + suspend fun set(uri: Uri, name: String) = withContext(Dispatchers.IO) { + runCatching { + val bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ -> + val scale = min(1f, 1600f / max(info.size.width, info.size.height)) + if (scale < 1f) decoder.setTargetSize((info.size.width * scale).roundToInt(), (info.size.height * scale).roundToInt()) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + store(bitmap, name) + } + } + + /** Store an image file (launch extra `wallpaper` for screenshots and tests). */ + suspend fun set(path: File) = withContext(Dispatchers.IO) { + runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path.path, bounds) + var sample = 1 + while (max(bounds.outWidth, bounds.outHeight) / (sample * 2) >= 1600) sample *= 2 + val bitmap = BitmapFactory.decodeFile(path.path, BitmapFactory.Options().apply { inSampleSize = sample })!! + store(bitmap, path.name) + } + } + + private suspend fun store(bitmap: Bitmap, name: String) { + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + prefs.edit().putString("name", name).apply() + withContext(Dispatchers.Main) { changed() } + } + + fun setEffect(effect: WallpaperEffect) { + prefs.edit().putString("effect", effect.name).apply() + changed() + } + + fun remove() { + file.delete() + prefs.edit().remove("name").apply() + changed() + } + + /** Render for an appearance (null when no wallpaper is set). */ + suspend fun render(dark: Boolean): Render? { + val s = _state.value + if (!s.set) return null + val key = "${s.effect}-$dark" + synchronized(cache) { cache[key] }?.let { return it } + return withContext(Dispatchers.Default) { + runCatching { + val source = BitmapFactory.decodeFile(file.path) ?: return@runCatching null + val scale = min(1f, 1200f / max(source.width, source.height)) + val w = max(1, (source.width * scale).roundToInt()) + val h = max(1, (source.height * scale).roundToInt()) + val sized = Bitmap.createScaledBitmap(source, w, h, true).copy(Bitmap.Config.ARGB_8888, false) + val buffer = ByteBuffer.allocate(w * h * 4) + sized.copyPixelsToBuffer(buffer) + val out = wallpaperRender(buffer.array(), w.toUInt(), h.toUInt(), s.effect, !dark) + // Text sits over the top of the artwork: primary text keeps + // 4.5:1, secondary 3:1 against its worst pixels there. + val text = (if (dark) 0xE8E8EA else 0x27272C).toUInt() + val secondary = (if (dark) 0xA9A9AE else 0x62626A).toUInt() + val background = (if (dark) 0x060606 else 0xF3F3F5).toUInt() + val primary = wallpaperSafeOpacity(out, w.toUInt(), h.toUInt(), text, background, 0.6f, 4.5f, 1f) + val muted = wallpaperSafeOpacity(out, w.toUInt(), h.toUInt(), secondary, background, 0.6f, 3f, 1f) + val rendered = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + rendered.copyPixelsFromBuffer(ByteBuffer.wrap(out)) + Render(rendered.asImageBitmap(), min(primary, muted)) + }.getOrNull() + }?.also { r -> if (_state.value.generation == s.generation) synchronized(cache) { cache[key] = r } } + } + + companion object { + val effects = listOf(WallpaperEffect.NONE, WallpaperEffect.DITHER, WallpaperEffect.ASCII, WallpaperEffect.HALFTONE, WallpaperEffect.SCANLINES) + + fun label(e: WallpaperEffect) = when (e) { + WallpaperEffect.NONE -> "None" + WallpaperEffect.DITHER -> "Dither" + WallpaperEffect.ASCII -> "ASCII" + WallpaperEffect.HALFTONE -> "Halftone" + WallpaperEffect.SCANLINES -> "Scanlines" + } + + fun detail(e: WallpaperEffect) = when (e) { + WallpaperEffect.NONE -> "Shows the original artwork." + WallpaperEffect.DITHER -> "Rebuilds the artwork with a dithered color palette." + WallpaperEffect.ASCII -> "Recreates the artwork with colored characters." + WallpaperEffect.HALFTONE -> "Recreates the artwork with colored print dots." + WallpaperEffect.SCANLINES -> "Adds a pronounced horizontal display-line texture." + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/design/BrandMarks.kt b/apps/android/app/src/main/java/sh/zeron/android/design/BrandMarks.kt new file mode 100644 index 0000000000..38123468a7 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/design/BrandMarks.kt @@ -0,0 +1,67 @@ +package sh.zeron.android.design + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate + +/** + * Harness brand marks — the exact SVG path data the desktop bundles + * (crates/ui/assets/icons, the `-mark.svg` files), shared with iOS BrandMarks.swift. + */ +enum class BrandMark(val width: Float, val height: Float, val evenOdd: Boolean, val pathData: String) { + CLAUDE(256f, 257f, false, "m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"), + OPENAI(256f, 260f, false, "M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"), + CURSOR(466.73f, 532.09f, false, "M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"), + DEVIN(263f, 300f, false, "M1.84577e-10 100.797V38.6224C1.88813e-05 35.8899 1.45777 33.365 3.82414 31.9988L57.6539 0.920185C60.0203 -0.446051 62.9358 -0.446051 65.3022 0.920185L119.132 31.9988C121.498 33.3651 122.956 35.8899 122.956 38.6224V70.375C123.192 80.9262 128.769 91.103 138.577 96.7655C148.384 102.428 159.986 102.169 169.242 97.0979L196.74 81.2217C199.107 79.8554 202.022 79.8554 204.388 81.2217L258.218 112.3C260.585 113.667 262.042 116.191 262.042 118.924V181.081C262.042 183.814 260.585 186.339 258.218 187.705L204.388 218.783C202.022 220.15 199.107 220.15 196.74 218.783L169.464 203.036C160.173 197.847 148.465 197.536 138.579 203.244C128.772 208.906 123.195 219.083 122.959 229.634V261.378C122.959 264.111 121.501 266.635 119.135 268.002L65.3048 299.08C62.9384 300.447 60.0229 300.447 57.6566 299.08L3.82673 268.002C1.46036 266.635 0.00260562 264.111 0.00260562 261.378V199.221C0.0026245 196.488 1.46038 193.963 3.82675 192.597L57.6565 161.519C60.0229 160.152 62.9384 160.152 65.3048 161.519L92.8656 177.431C102.11 182.467 113.679 182.711 123.463 177.062C133.349 171.355 138.934 161.06 139.086 150.419C138.85 139.868 133.274 128.601 123.466 122.938C113.658 117.276 102.056 117.534 92.8007 122.606L65.1758 138.645C62.7991 140.025 59.8649 140.024 57.4894 138.642L3.80216 107.407C1.44816 106.038 -1.88809e-05 103.52 1.84577e-10 100.797Z"), + GROK(16f, 16f, false, "M0.58392448,14.9254204 L0.8326,14.66295 C2.004465,13.42985 3.1678,12.2082 2.45814,10.48105 C1.50813,8.1701 2.061355,5.4619 3.820575,3.70057 C5.6495,1.8709 8.3431,1.40957 10.59295,2.336505 C11.0907,2.52161 11.5245,2.785025 11.86295,3.02993 L9.98425,3.89849 C8.235,3.163775 6.23115,3.66355 5.0081,4.88809 C3.354105,6.5426 3.019895,9.4117 4.95835,11.2656 L-0.335,15.99995 C-0.066496,15.62975 0.2538896,15.275934 0.58392448,14.9254204 Z M14.0391,2.288155 L16.33165,0 L16.20795,0.172288 C14.4658,2.574355 13.6153,3.749045 14.29795,6.6879 C14.76445,8.68415 14.261,10.90255 12.63545,12.53005 C10.5861,14.58325 7.3066,15.0403 4.6059,13.19215 L6.48885,12.3193 C8.2125,12.99705 10.0983,12.69945 11.4536,11.34255 C12.80895,9.9856 13.1133,8.00925 12.4321,6.3647 C12.30265,6.05285 11.9144,5.97455 11.64275,6.1753 L6.102,10.27035 L14.0391,2.288155 Z"), + HERMES(24f, 24f, true, "M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05-.124-.098-.24-.194-.354-.291-.011-.01-.016-.027-.025-.042zM8.396 9.412c.195-.032.39-.06.588-.05a.54.54 0 01.148.026c.202.071.402.147.601.224.028.01.05.036.075.055l-.013.027a9.203 9.203 0 01-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144c.275.139.55.276.823.417.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137c-.081-.01-.161-.018-.244-.027l-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.474.474 0 01.159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.397 1.397 0 00-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.39 1.39 0 01-.218.211.477.477 0 01-.641-.05 1.36 1.36 0 01-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.897.897 0 00-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172a14.82 14.82 0 00-.039-.049l.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476z M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z M11.981.009c.226-.012.453-.011.679 0 .247.01.495.024.74.062.401.064.798.157 1.19.273.463.138.92.299 1.356.511a7.31 7.31 0 012.948 2.642c.292.469.536.963.739 1.479.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a7.56 7.56 0 01-.017.88c-.037.413-.1.818-.226 1.212a5.017 5.017 0 01-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.59 3.59 0 00.325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 01-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.522.522 0 00-.033.406.359.359 0 01.013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.206 1.206 0 01-.06-.233c-.04-.314-.155-.6-.308-.87a3.906 3.906 0 00-.73-.91 2.129 2.129 0 00-.897-.524 4.093 4.093 0 00-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375.013.062-.002.109-.053.144-.047.033-.122.034-.163-.01a.455.455 0 01-.08-.14c-.03-.073-.038-.159-.078-.225a7.314 7.314 0 00-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 00-.736.278 5.14 5.14 0 00-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531a5.227 5.227 0 00-.012.051.096.096 0 01-.09.076h-.31c-.046 0-.082-.048-.072-.094.023-.108.045-.216.07-.324.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.196.196 0 00-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.314 7.314 0 00-1.07 1.767.845.845 0 00-.04.12.075.075 0 01-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24.029-.027.053-.057.079-.085l-.018-.025-.135.041c-.034.017-.07.031-.102.05-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488-.125.143-.25.286-.373.431A.153.153 0 019.89 24H8.762a.316.316 0 00.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a4.45 4.45 0 00-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037.071-.088.14-.178.209-.268.001-.002-.006-.012-.012-.024-.014.004-.03.006-.045.013-.176.09-.352.181-.527.274a.363.363 0 01-.168.042H5.202c-.026 0-.039-.036-.019-.053.21-.178.402-.374.558-.605.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a1.943 1.943 0 01-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.513 3.513 0 011.97 20.6a2.286 2.286 0 01-.808-1.13 3.569 3.569 0 01-.16-1.245c.002-.034.016-.067.024-.1.032.023.046.043.05.066.033.153.059.308.096.46.086.355.257.664.516.92.258.256.571.419.91.532.358.118.717.138 1.07-.016a1.89 1.89 0 00.621-.452c.328-.348.533-.76.648-1.223.009-.034.005-.071.007-.11-.015.006-.026.006-.03.011-.031.05-.064.1-.093.152-.284.502-.679.887-1.196 1.135-.351.17-.718.255-1.11.159a1.607 1.607 0 01-.971-.64 2.006 2.006 0 01-.368-.924 2.903 2.903 0 01.02-.886c.05-.439.466-1.17.742-1.271-.02.063-.035.112-.053.16-.043.116-.097.227-.13.345a1.901 1.901 0 00-.05.82c.033.212.09.416.204.6.147.236.346.407.62.465.11.023.225.014.338.018a.576.576 0 00.386-.131c.164-.128.282-.292.366-.481.168-.375.24-.777.309-1.179.05-.296.093-.594.133-.893.039-.281.071-.563.104-.845.026-.232.048-.464.074-.696.024-.228.052-.455.076-.683.024-.227.047-.455.069-.683.013-.14.022-.28.034-.42l.037-.417c.022-.25.041-.5.065-.748.008-.082-.02-.132-.09-.177a2.46 2.46 0 01-.492-.418c-.1-.109-.188-.228-.282-.342-.035-.042-.056-.097-.116-.118a2.084 2.084 0 00.275.597c.06.092.131.176.196.265.063.086.182.115.234.226-.028.003-.046.01-.06.006a4.74 4.74 0 01-.22-.057 2.71 2.71 0 01-1.287-.819c-.435-.487-.656-1.076-.71-1.723a5.206 5.206 0 01.014-1.06c.072-.602.22-1.186.45-1.745.155-.376.338-.741.526-1.102.205-.393.466-.75.765-1.076.512-.559 1.104-1.024 1.726-1.448.717-.49 1.478-.898 2.277-1.233C8.244.828 8.767.632 9.31.494c.655-.166 1.31-.33 1.982-.415.229-.03.458-.058.688-.07zm-1.847 22.82c-.07.06-.147.111-.207.18-.238.27-.464.549-.668.869l-.044.108a.177.177 0 00.093-.057c.174-.19.351-.378.519-.574.104-.122.195-.255.288-.386.024-.034.03-.08.046-.12l-.027-.02zm1.65-3.695a5.51 5.51 0 00-.653.593l-.37.386a.963.963 0 01-.377.25 1.372 1.372 0 01-.467.09c-.044 0-.087.006-.151.012.028.058.043.097.064.131.15.242.301.482.45.724.136.22.276.438.399.666.068.125.105.267.156.404.077.027.14-.018.202-.048.29-.135.579-.274.867-.412.213-.101.437-.186.636-.31.347-.215.68-.455 1.018-.685.015-.01.026-.028.042-.046-.023-.019-.038-.037-.056-.044-.287-.111-.527-.3-.77-.482a5.319 5.319 0 01-.506-.42 1.757 1.757 0 01-.41-.653c-.019-.049-.045-.095-.075-.156zm-5.847.264c-.06.096-.097.194-.132.293a3.38 3.38 0 01-.555 1.01c-.2.25-.455.412-.762.493-.23.06-.464.076-.7.07-.048-.002-.097.002-.158.005.016.04.021.066.035.085.1.145.23.246.4.295.157.046.316.034.498.023.181-.037.343-.115.485-.234.238-.199.402-.454.536-.732.175-.363.264-.751.342-1.144.01-.053.008-.11.011-.164zm14.945-4.586c.008.029.016.057.027.107.024.155.051.31.072.464.03.219.067.437.078.657.017.344.027.689-.014 1.033-.037.315-.063.633-.116.946a6.153 6.153 0 01-.46 1.518c-.008.018-.01.039-.02.082.047-.03.077-.042.098-.064.085-.083.17-.167.248-.255.271-.305.458-.66.596-1.043.18-.498.228-1.011.145-1.531-.103-.65-.33-1.263-.597-1.881a9.055 9.055 0 00-.024-.055l-.033.022zM5.797 8.29a.26.26 0 00.018.153c.124.251.25.501.379.75.025.049.066.09.03.163-.284.06-.578.119-.88.255.059.038.097.06.132.087.042.032.112.058.09.12-.01.033-.075.048-.117.072.017.01.043.021.067.036.166.102.33.207.447.368.138.192.229.404.188.644-.079.469-.306.85-.69 1.132-.054.04-.106.083-.161.122a.243.243 0 00-.103.245.77.77 0 00.055.195c.083.196.22.35.375.492.083.076.159.164.222.257a.37.37 0 01.025.377c-.023.05-.05.099-.076.148-.03.06-.028.111.022.162.041.042.08.089.112.138.038.058.078.079.147.05a.486.486 0 01.333-.006c.16.046.302.126.444.21.13.077.264.149.4.219.067.035.14.05.219.026.071-.022.124.01.145.076.02.064-.003.108-.074.139-.07.03-.137.063-.209.088-.1.035-.201.073-.314.077-.013-.107.11-.088.127-.159-.206-.126-.643-.145-.801-.034.063.112.035.21-.096.313-.13-.1-.025-.202.002-.3a.209.209 0 00-.249.17c-.015.101.067.216.178.224.108.007.218-.005.326-.012.06-.005.12-.027.199 0-.103.123-.248.127-.357.19.002.05.07.086.019.131-.053.048-.095-.001-.132-.03-.08-.063-.16-.126-.231-.197a.474.474 0 01-.157-.311.52.52 0 00-.043-.172c-.032-.074-.032-.137.033-.19-.018-.03-.028-.053-.045-.072a1.222 1.222 0 01-.196-.369c-.053-.137-.046-.264.048-.381.024-.03.05-.06.064-.095a.664.664 0 00.047-.168c.017-.165-.064-.287-.182-.387-.186-.156-.36-.322-.46-.551-.005-.011-.024-.017-.037-.026-.011.017-.024.027-.025.038-.019.185-.045.37-.052.557-.014.377.058.743.162 1.104.118.41.289.798.488 1.173.267.502.537 1.002.812 1.5.055.098.13.189.208.27.198.202.452.272.724.273.202 0 .404-.006.605-.026.295-.03.59-.073.884-.113.183-.025.365-.057.548-.08.21-.026.38.073.522.21.16.156.305.327.447.5.22.265.397.56.554.867.05.098.07.1.147.03.13-.121.26-.242.394-.36.067-.059.088-.12.067-.213a3.535 3.535 0 01-.085-.796c.002-.157.006-.314.018-.471.015-.224.03-.45.06-.672a59.114 59.114 0 01.362-2.298c.087-.493.182-.984.268-1.477.06-.347.118-.694.162-1.043.034-.273.055-.55.063-.825.011-.332.003-.665.002-.998 0-.077.004-.155-.01-.23-.028-.142-.01-.155-.162-.19a5.826 5.826 0 00-.607-.107c-.146-.018-.207-.053-.221-.19-.006-.049-.025-.098-.041-.146-.009-.025-.024-.048-.046-.09l-.025.264c-.009.096-.029.116-.127.115-.055 0-.11-.008-.164-.008-.476 0-.952-.008-1.426.032-.095.008-.173-.015-.226-.103-.04-.066-.088-.126-.134-.186-.063-.084-.086-.093-.182-.06-.195.068-.388.138-.582.21a2.71 2.71 0 00-.675.394.986.986 0 01-.323.168c-.033.01-.07.008-.127.013.02-.066.024-.114.047-.15.064-.105.135-.205.205-.306.023-.033.049-.063.073-.095l-.015-.023-.201.037c-.146.04-.296.07-.437.122-.148.053-.266.023-.386-.072a3.623 3.623 0 01-.733-.786l-.093-.132zm8.592 8.963l-.147.09c-.22.134-.44.266-.659.402-.093.058-.184.12-.27.188-.085.07-.124.161-.072.272.047.1.093.2.147.294.047.08.124.138.213.147.11.01.228.012.336-.012.217-.05.372-.205.528-.357a.291.291 0 00.087-.308c-.046-.18-.079-.365-.118-.547-.011-.052-.027-.103-.045-.169zm-.257-2.409c-.12.291-.205.597-.325.91-.151.433-.294.87-.435 1.323.036-.01.054-.01.067-.018.261-.16.522-.324.785-.484.054-.033.071-.078.065-.138-.012-.13-.024-.262-.034-.393l-.068-.886c-.008-.103-.02-.206-.029-.31-.009 0-.017-.002-.026-.004zm3.081-8.13l.099.285c.08.231.159.463.24.714l.58 1.952c.187.63.372 1.262.558 1.893.114.382.235.762.343 1.146.072.257.126.519.186.799.044.206.087.413.127.64.034.106.023.226.077.325l.025-.006-.068-.362c-.038-.206-.077-.412-.113-.638-.015-.07-.029-.141-.046-.211-.095-.396-.177-.796-.29-1.187-.196-.685-.413-1.364-.618-2.046-.165-.549-.322-1.1-.488-1.648-.069-.227-.15-.45-.226-.695l-.117-.336c-.037-.107-.075-.216-.115-.322-.04-.106-.084-.21-.127-.314a7.558 7.558 0 01-.027.01zM6.225 14.304c-.063-.001-.115.014-.134.083a.35.35 0 00.41.012 4.533 4.533 0 00-.276-.095zM5.23 11.98c-.026-.027-.057-.048-.075.002-.012.032-.007.07-.01.113.082-.037.082-.037.085-.115zm.062-1.189a.135.135 0 00-.088.056.197.197 0 00-.025.11c.005.152.01.306.026.457a.751.751 0 00.066.218c.061.136.157.167.288.101.055-.027.06-.054.025-.11a4.52 4.52 0 01-.129-.211c-.015-.068-.066-.131-.033-.207.04-.09-.076-.116-.074-.19V10.874c-.003-.038-.006-.087-.056-.083zm-.017-.968a.867.867 0 00-.467.127c-.076.045-.084.07-.05.158.034.087.07.173.115.254.064.117.09.125.21.077a.657.657 0 01.336-.053c.202.022.357.136.504.264l.092.077c.007-.006.014-.013.022-.018-.019-.105-.035-.226-.149-.264-.157-.053-.324-.075-.508-.117l-.24-.005c.24-.169.452-.044.687.009-.063-.115-.153-.147-.23-.193-.082-.05-.17-.092-.25-.144-.06-.037-.12-.08-.072-.172zm10.233.325c-.23-.01-.427.08-.608.211-.034.026-.06.065-.105.117.087.026.15.046.232.065.044-.015.088-.03.13-.046.306-.114.61-.115.904.031.126.063.237.04.366-.005-.02-.031-.03-.054-.045-.071a.986.986 0 00-.448-.273c-.14-.044-.284-.024-.426-.03zM7.99 6.483a.308.308 0 00.002.133c.08.321.156.643.242.962.104.387.27.75.456 1.103.02.037.061.08.098.087a.404.404 0 00.253-.051l-.472-.84c-.23-.448-.405-.92-.579-1.394zM10.397.497c-.2-.008-.405.004-.603.034-.236.035-.47.087-.7.152-.287.08-.569.18-.852.273-.04.013-.074.038-.11.058.028.014.05.018.07.014.287-.068.58-.085.873-.09.134-.002.269.009.402.025.19.024.382.048.57.09.456.104.874.3 1.265.556.464.306.888.66 1.257 1.078.205.232.395.475.56.739.17.274.315.561.449.856.273.601.456 1.232.6 1.876.04.173.07.348.1.524.017.104.065.167.17.19.122.028.2.105.22.251-.003.102-.06.174-.129.24a1.065 1.065 0 00-.268.358.164.164 0 00.083-.039c.08-.086.162-.172.235-.265a.56.56 0 00.13-.333c.009-.05.022-.1.024-.15.007-.124-.017-.15-.143-.168-.025-.004-.049-.014-.073-.015-.082-.007-.125-.063-.137-.131-.033-.198-.004-.355.247-.408.086-.018.174-.03.26-.042.158-.023.315-.053.473-.067.14-.012.19.033.226.167.008.029.018.057.021.087.019.179-.008.225-.141.288-.027.013-.055.024-.078.042a.148.148 0 00-.051.067c-.039.144.073.382.206.445l.673.32c.023.011.05.015.075.023l.018-.026c-.015-.008-.032-.013-.044-.024a2.27 2.27 0 00-.544-.32 4.898 4.898 0 00-.173-.075.203.203 0 01-.126-.191c-.003-.085.045-.154.128-.187l.059-.025c.099-.044.118-.076.112-.187a.384.384 0 00-.008-.063c-.067-.294-.123-.59-.205-.88a9.478 9.478 0 00-.826-2.036 7.465 7.465 0 00-1.39-1.805 4.536 4.536 0 00-1.177-.824 3.656 3.656 0 00-1.016-.328 6.155 6.155 0 00-.712-.074zm6.719 5.955c.01.014.018.028.038.034l-.022-.044-.016.01zM4.103 3.917a.062.062 0 01-.03.012.455.455 0 01-.04.039c-.01.01-.02.02-.045.04l-.363.354c-.088.085-.17.178-.266.253-.284.22-.425.53-.544.855a.132.132 0 00-.007.071c.013.055.033.108.052.168l.074.026c-.017.056-.03.105-.047.152-.058.164-.118.327-.175.491-.005.015.008.036.019.077.08-.175.158-.33.225-.489.228-.544.484-1.074.819-1.561.09-.133.182-.266.283-.401.004-.006.007-.013.022-.03.001-.016.003-.032.015-.04l.008-.017zm12.976 2.408a.023.023 0 01.009.019.073.073 0 00-.006.01.188.188 0 00.007.02l.018.022c.002-.007.007-.016.005-.021-.003-.01-.012-.018-.02-.038a1.331 1.331 0 01-.013-.012zM4.199 4.48c-.003.004-.008.008-.027.014-.005.013-.011.025-.031.047a2.085 2.085 0 01-.124.167c-.048.07-.116.055-.181.041-.134-.028-.228.016-.287.143-.089.187-.187.37-.273.56-.049.108-.11.216-.118.36.081.003.154.007.228.008h.228a2.563 2.563 0 01-.079.264c-.01.052-.022.103-.033.155l.02.004c.018-.046.037-.092.067-.153.066-.142.13-.285.2-.426.02-.04.034-.1.116-.092 0 .043.004.084 0 .124-.005.045-.017.09-.028.143.141.043.086.174.115.269.102-.022.104-.195.248-.144v.205l.017.002.439-1.059c-.13 0-.246-.02-.358.033-.024.011-.058-.001-.108-.004.075-.15.139-.278.211-.417a.128.128 0 01.025-.036c0-.015-.001-.03.008-.038l.006-.02c-.005.006-.01.011-.028.017-.004.012-.009.024-.026.045a.085.085 0 01-.032.033c-.123.157-.09.164-.258.106-.079-.027-.078-.028-.047-.144.028-.046.056-.093.098-.15 0-.016-.001-.032.007-.042L4.2 4.48zm2.073-.67c-.003.006-.007.011-.027.016-.094.125-.194.246-.28.377-.155.238-.301.481-.451.723-.14.224-.345.368-.575.481-.017.008-.04.006-.079.011.012-.059.016-.109.033-.153a6.076 6.076 0 01.229-.518l-.007-.02a.138.138 0 01-.035.025c-.028.05-.055.1-.093.164-.26.424-.443.817-.442.95.024.004.048.011.073.013.177.013.188.007.26-.165.03-.07.077-.12.147-.15l.175-.07c.044-.018.085-.057.146-.032.003.05-.01.11.014.145.042.062.044.125.047.193.002.049.017.098.026.147.029-.034.039-.065.05-.097.142-.39.277-.782.428-1.17.1-.256.22-.504.33-.756.013-.03.013-.067.03-.092V3.81zm3.987-.34c0 .045.01.084.021.123.042.16.094.318.124.48.024.133.023.27.028.406 0 .033-.019.067-.032.11-.094-.058-.047-.158-.106-.215h-.125c-.015.072-.01.152-.046.2-.066.085-.155.154-.236.227-.043.038-.078.018-.103-.025l-.046-.087c-.065.035-.117.069-.172.093-.116.051-.235.095-.35.147-.085.038-.09.053-.07.147.014.075.034.148.047.223.013.072.05.109.123.124.233.05.462.115.657.265.058-.102.058-.102.168-.151.03-.014.06-.03.092-.042.08-.03.115-.017.15.06.023.048.041.098.066.158.06-.14-.042-.267.017-.416.157.18.24.39.375.567a.235.235 0 00.022-.098c.002-.124 0-.247.002-.371 0-.034.013-.067.02-.1l.032-.003c.11.155.13.354.226.52a3.036 3.036 0 00-.01-.392c-.004-.045 0-.074.05-.088.08.036.116.14.215.158-.03-.275-.423-1.137-.798-1.635-.114-.127-.2-.28-.34-.386zm-2.667.696c-.019.034-.03.05-.037.067-.061.185-.125.37-.18.556-.031.105-.087.169-.195.19-.09.019-.178.052-.268.073-.038.009-.089.015-.118-.003-.024-.016-.025-.069-.036-.106-.064.076-.082.087-.17.047-.133-.062-.262-.135-.393-.201-.048-.025-.093-.063-.17-.03-.043.12-.091.25-.137.382-.099.28-.087.242.095.453.046.048.102.03.154.023.054-.009.106-.03.16-.036.13-.013.26-.08.367-.015.204-.064.387-.122.571-.178.05-.015.089.005.114.054.022.042.034.093.082.121.038-.056-.013-.128.063-.178l.14.241-.042-1.46zm.278.358c-.096-.01-.107.01-.11.108-.002.038-.003.078.002.115.03.2.099.386.174.57.002.006.012.01.022.015l.078-.05c.052.036.081.088.153.088.205-.002.41.014.616.012.099-.001.158.042.205.12.018.03.024.077.088.066l-.08-.394c-.05-.195-.085-.395-.172-.589-.057.057-.114.068-.18.046a.72.72 0 00-.135-.028c-.22-.028-.44-.059-.66-.08zm10.254-1.727c.089.163.155.316.139.491-.016.168.026.342-.044.516-.047-.033-.088-.082-.112-.075-.117.035-.164-.057-.227-.115a4.772 4.772 0 01-.286-.29l-.104-.113a4.856 4.856 0 01-.023.019c.035.046.07.093.11.156.04.064.084.127.122.193.034.058.065.118.031.205-.082-.01-.164-.019-.246-.032-.06-.01-.101 0-.124.07-.031.098-.037.096-.15.09.02.042.036.08.057.116.041.074.03.138-.03.196-.06.06-.118.122-.178.181a.175.175 0 01-.185.046c-.222-.061-.447-.113-.67-.174-.032-.009-.063-.04-.086-.068-.03-.04-.052-.087-.08-.13-.044-.07-.09-.138-.136-.207a.18.18 0 00-.014.105c.012.127.03.253.035.38.005.1-.024.12-.121.104-.104-.017-.206-.04-.31-.058-.064-.012-.131-.028-.202.03l.081.208c.09 0 .166-.01.237.002a.819.819 0 01.458.251c.078.083.154.168.241.26l.018-.005c-.004-.006-.008-.013-.01-.04.014-.056-.062-.118.018-.178.031.03.064.057.088.09.058.078.111.159.169.257l.089.141.024-.013a2093.819 2093.819 0 01-.427-.934c.055.007.083.007.108.016.193.07.385.142.577.216.074.028.147.06.219.094.062.028.112.018.157-.033.05-.056.102-.112.154-.167.05-.051.095-.046.132.014.016.025.026.053.04.08.071.138.143.277.217.433l.159.308.025-.011c-.044-.106-.07-.218-.138-.334-.057-.182-.168-.346-.206-.545.136.034.362.326.567.732l.057.074.018-.011a1.563 1.563 0 01-.052-.127c-.046-.145-.097-.29-.136-.436-.022-.083-.036-.173.022-.26l.109.058-.026-.207.027-.016c.022.02.05.036.065.06.073.108.143.22.215.33.01.016.029.029.043.043-.036-.217-.2-.38-.229-.626l.155.112c.014-.166.012-.319.042-.465.032-.158-.023-.297-.063-.445.024.004.036.006.055.025.092.124.183.249.277.371.02.027.05.047.069.087l.04.063.019-.015a.293.293 0 01-.053-.082 27.922 27.922 0 01-.332-.49c-.221-.311-.363-.467-.485-.521zm-6.57.327c-.003.161.092.275.069.415l-.368.087c.09.139.032.237-.052.331-.05.057-.092.122-.143.178-.037.04-.046.078-.018.126l.16.275c.029.048.072.066.128.064.076-.003.152 0 .228-.001.116-.003.216.022.275.137.006.014.02.024.044.052.004-.059-.003-.098.01-.13.016-.04.04-.099.072-.108.084-.023.173-.024.26-.03.013-.001.027.018.04.029l.071.065c.019-.11-.082-.198-.024-.31l.126.04c-.026-.123-.07-.245-.071-.366 0-.123.051-.243.115-.36.107.062.16.156.234.253.183.265.36.533.494.834.165-.078.27.068.407.088-.003-.106-.133-.441-.197-.492a.142.142 0 00-.102-.028c-.06.011-.119.039-.191.063-.025-.039-.056-.078-.077-.122a3.936 3.936 0 00-.473-.783c-.076-.094-.16-.182-.228-.26l-.391.285c-.049.035-.094.03-.132-.017l-.169-.207c-.025-.03-.053-.059-.097-.108z"), + PI(800f, 800f, true, "M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65ZM517.36 400H634.72V634.72H517.36Z"), + OPENCODE(24f, 30f, true, "M24 0H0V30H24V0ZM18 6H6V24H18V6Z"), + ANTIGRAVITY(24f, 24f, true, "M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z"); + + companion object { + fun forHarness(harness: String?): BrandMark = when (harness) { + "codex" -> OPENAI + "cursor" -> CURSOR + "devin" -> DEVIN + "grok" -> GROK + "hermes" -> HERMES + "pi" -> PI + "opencode" -> OPENCODE + "antigravity" -> ANTIGRAVITY + // claude-code + mock share the mark, like the desktop. + else -> CLAUDE + } + + /** The mark's own color, or null when it takes the caller's tint. */ + fun brandTint(harness: String?): Color? = when (harness) { + null, "claude-code", "mock" -> Color(0xFFD97757) + else -> null + } + } +} +private val parsed = HashMap() + +private fun BrandMark.path(): androidx.compose.ui.graphics.Path = parsed.getOrPut(this) { + androidx.compose.ui.graphics.vector.PathParser().parsePathString(pathData).toPath().also { + it.fillType = if (evenOdd) androidx.compose.ui.graphics.PathFillType.EvenOdd else androidx.compose.ui.graphics.PathFillType.NonZero + } +} + +/** A harness mark, aspect-fit and centered; Claude keeps its brand orange. */ +@androidx.compose.runtime.Composable +fun HarnessMark(harness: String?, size: androidx.compose.ui.unit.Dp, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier, tint: Color = androidx.compose.material3.LocalContentColor.current) { + val mark = BrandMark.forHarness(harness) + val color = BrandMark.brandTint(harness) ?: tint + androidx.compose.foundation.Canvas(modifier.then(androidx.compose.ui.Modifier.size(size))) { + val scale = minOf(this.size.width / mark.width, this.size.height / mark.height) + val dx = (this.size.width - mark.width * scale) / 2 + val dy = (this.size.height - mark.height * scale) / 2 + translate(dx, dy) { + scale(scale, pivot = androidx.compose.ui.geometry.Offset.Zero) { + drawPath(mark.path(), color) + } + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/design/Palette.kt b/apps/android/app/src/main/java/sh/zeron/android/design/Palette.kt new file mode 100644 index 0000000000..0d2b321a02 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/design/Palette.kt @@ -0,0 +1,87 @@ +package sh.zeron.android.design + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import uniffi.zeron_core.ColorRole + +/** + * Transcript paint roles → ARGB for one appearance. Paint-only: nothing here + * affects layout. Neutrals are Zeron's (the iOS palette); accent roles follow + * the Material scheme so wallpaper colors flow into links and quotes. + */ +class TranscriptPalette(dark: Boolean, scheme: ColorScheme) { + private val colors = IntArray(ColorRole.entries.size) + + val background: Int = scheme.background.toArgb() + + operator fun get(role: ColorRole): Int = colors[role.ordinal] + + init { + fun c(light: Long, darkHex: Long, alpha: Float = 1f): Int { + val base = Color(if (dark) darkHex or 0xFF000000 else light or 0xFF000000) + return base.copy(alpha = alpha).toArgb() + } + fun w(lightAlpha: Float, darkAlpha: Float): Int = + if (dark) Color.White.copy(alpha = darkAlpha).toArgb() else Color.Black.copy(alpha = lightAlpha).toArgb() + val accent = scheme.primary + for (role in ColorRole.entries) { + colors[role.ordinal] = when (role) { + ColorRole.TEXT -> scheme.onSurface.toArgb() + ColorRole.TEXT_SECONDARY -> c(0x62626A, 0xA9A9AE) + ColorRole.TEXT_TERTIARY -> c(0x97979F, 0x6B6B72) + ColorRole.LINK, ColorRole.ACCENT -> accent.toArgb() + ColorRole.DANGER -> c(0xDC2626, 0xF87171) + ColorRole.SUCCESS -> c(0x15803D, 0x34D399) + ColorRole.WARNING -> c(0xA16207, 0xFACC15) + ColorRole.INLINE_CODE_TEXT -> c(0x3F3F46, 0xDCDCE0) + ColorRole.INLINE_CODE_BACKGROUND -> c(0xE9E9ED, 0x1A1A1E) + ColorRole.CODE_TEXT -> c(0x303035, 0xE8E8EA) + ColorRole.CODE_BACKGROUND -> c(0xFAFAFB, 0x0B0B0D) + ColorRole.CODE_BORDER -> c(0xE4E4E8, 0x1F1F23) + ColorRole.QUOTE_BAR -> accent.copy(alpha = 0.45f).toArgb() + ColorRole.RULE -> c(0xE2E2E6, 0x1E1E22) + ColorRole.TABLE_BORDER -> c(0xE2E2E6, 0x232327) + ColorRole.TABLE_HEADER_BACKGROUND -> c(0xF3F3F5, 0x121215) + ColorRole.USER_BUBBLE -> scheme.surfaceContainerHigh.toArgb() + ColorRole.CHIP_BACKGROUND -> c(0xE7E7EB, 0x1C1C20) + ColorRole.SYNTAX_KEYWORD -> c(0x5B43E8, 0x8B7CF6) + ColorRole.SYNTAX_STRING -> c(0x15803D, 0x34D399) + ColorRole.SYNTAX_COMMENT -> c(0x6B7280, 0x92929A) + ColorRole.SYNTAX_NUMBER, ColorRole.SYNTAX_CONSTANT -> c(0xA16207, 0xFACC15) + ColorRole.SYNTAX_FUNCTION -> c(0x2563EB, 0x60A5FA) + ColorRole.SYNTAX_TYPE -> c(0x7E22CE, 0xC084FC) + ColorRole.SYNTAX_VARIABLE -> c(0x303035, 0xE8E8EA) + ColorRole.SYNTAX_PROPERTY -> c(0x0E7490, 0x22D3EE) + ColorRole.SYNTAX_OPERATOR, ColorRole.SYNTAX_PUNCTUATION -> c(0x52525B, 0xA1A1AA) + ColorRole.SYNTAX_TAG -> c(0xBE185D, 0xF472B6) + ColorRole.SYNTAX_ATTRIBUTE -> c(0xB91C1C, 0xF87171) + ColorRole.SYNTAX_ESCAPE -> c(0x0E7490, 0x22D3EE) + ColorRole.TEXT_FAINT -> c(0x797981, 0x85858A) + ColorRole.TEXT_SOFT -> c(0x303035, 0xE8E8EA, 0.85f) + ColorRole.TOOL_RAIL -> w(0.162f, 0.12f) + ColorRole.TOOL_BADGE -> w(0.06f, 0.06f) + ColorRole.TOOL_WELL -> if (dark) Color.Black.copy(alpha = 0.16f).toArgb() else Color.White.copy(alpha = 0.16f).toArgb() + ColorRole.AGENT_CARD -> w(0.03f, 0.03f) + ColorRole.AGENT_CARD_BORDER -> w(0.0945f, 0.07f) + ColorRole.AGENT_TILE -> w(0.08f, 0.08f) + ColorRole.DIFF_ADD_WASH -> c(0x15803D, 0x34D399, 0.055f) + ColorRole.DIFF_DEL_WASH -> c(0xDC2626, 0xF87171, 0.055f) + ColorRole.DIFF_ADD_BAR -> c(0x15803D, 0x34D399, 0.55f) + ColorRole.DIFF_DEL_BAR -> c(0xDC2626, 0xF87171, 0.55f) + ColorRole.DIFF_HUNK -> accent.copy(alpha = if (dark) 0.08f else 0.07f).toArgb() + } + } + } +} + +/** Project tones — the desktop's monogram palette, by `project_color_index`. */ +object ProjectColors { + private val light = longArrayOf(0x475569, 0x2563EB, 0x7C3AED, 0xBE123C, 0xA16207, 0x047857, 0x0F766E, 0xC2410C) + private val dark = longArrayOf(0x94A3B8, 0x93C5FD, 0xC4B5FD, 0xFDA4AF, 0xFCD34D, 0x6EE7B7, 0x5EEAD4, 0xFDBA74) + + fun color(index: Int, isDark: Boolean): Color { + val table = if (isDark) dark else light + return Color(table[Math.floorMod(index, table.size)] or 0xFF000000) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/design/Theme.kt b/apps/android/app/src/main/java/sh/zeron/android/design/Theme.kt new file mode 100644 index 0000000000..7aacc3a02e --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/design/Theme.kt @@ -0,0 +1,201 @@ +package sh.zeron.android.design + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** Zeron Light / Zeron Dark (crates/theme builtins): cool neutrals, violet accent. */ +private val ZeronLight = lightColorScheme( + primary = Color(0xFF5B43E8), + onPrimary = Color.White, + primaryContainer = Color(0xFFE5E0FF), + onPrimaryContainer = Color(0xFF1E0E6B), + inversePrimary = Color(0xFF8B7CF6), + secondary = Color(0xFF5E5A71), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE4E1F0), + onSecondaryContainer = Color(0xFF1C1A29), + tertiary = Color(0xFF0F766E), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFCCF2EC), + onTertiaryContainer = Color(0xFF00201D), + background = Color(0xFFF3F3F5), + onBackground = Color(0xFF27272C), + surface = Color(0xFFF3F3F5), + onSurface = Color(0xFF27272C), + surfaceVariant = Color(0xFFE7E7EB), + onSurfaceVariant = Color(0xFF62626A), + surfaceTint = Color(0xFF5B43E8), + surfaceBright = Color(0xFFFFFFFF), + surfaceDim = Color(0xFFE2E2E6), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFFAFAFB), + surfaceContainer = Color(0xFFEEEEF1), + surfaceContainerHigh = Color(0xFFE8E8EC), + surfaceContainerHighest = Color(0xFFE2E2E7), + outline = Color(0xFF97979F), + outlineVariant = Color(0xFFE2E2E6), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFDE2E2), + onErrorContainer = Color(0xFF5F1111), + inverseSurface = Color(0xFF27272C), + inverseOnSurface = Color(0xFFF3F3F5), + scrim = Color.Black, +) + +private val ZeronDark = darkColorScheme( + primary = Color(0xFF8B7CF6), + onPrimary = Color(0xFF1A0F5C), + primaryContainer = Color(0xFF2F2670), + onPrimaryContainer = Color(0xFFE5E0FF), + inversePrimary = Color(0xFF5B43E8), + secondary = Color(0xFFC8C3DC), + onSecondary = Color(0xFF302D40), + secondaryContainer = Color(0xFF26242F), + onSecondaryContainer = Color(0xFFE4E1F0), + tertiary = Color(0xFF5EEAD4), + onTertiary = Color(0xFF003731), + tertiaryContainer = Color(0xFF0C3A35), + onTertiaryContainer = Color(0xFFCCF2EC), + background = Color(0xFF060606), + onBackground = Color(0xFFE8E8EA), + surface = Color(0xFF060606), + onSurface = Color(0xFFE8E8EA), + surfaceVariant = Color(0xFF1C1C20), + onSurfaceVariant = Color(0xFFA9A9AE), + surfaceTint = Color(0xFF8B7CF6), + surfaceBright = Color(0xFF26262A), + surfaceDim = Color(0xFF060606), + surfaceContainerLowest = Color(0xFF000000), + surfaceContainerLow = Color(0xFF0E0E10), + surfaceContainer = Color(0xFF131316), + surfaceContainerHigh = Color(0xFF1A1A1E), + surfaceContainerHighest = Color(0xFF222227), + outline = Color(0xFF6B6B72), + outlineVariant = Color(0xFF1E1E22), + error = Color(0xFFF87171), + onError = Color(0xFF450A0A), + errorContainer = Color(0xFF3B1212), + onErrorContainer = Color(0xFFFDE2E2), + inverseSurface = Color(0xFFE8E8EA), + inverseOnSurface = Color(0xFF111113), + scrim = Color.Black, +) + +/** Geist, from the same asset bytes the transcript measures. */ +val Geist: FontFamily by lazy { + FontFamily( + Font("Geist.ttf", LocalAssets.manager, FontWeight.Normal), + Font("Geist-Medium.ttf", LocalAssets.manager, FontWeight.Medium), + Font("Geist-SemiBold.ttf", LocalAssets.manager, FontWeight.SemiBold), + Font("Geist-Bold.ttf", LocalAssets.manager, FontWeight.Bold), + Font("Geist-Italic.ttf", LocalAssets.manager, FontWeight.Normal, FontStyle.Italic), + ) +} + +val GeistMono: FontFamily by lazy { + FontFamily( + Font("GeistMono.ttf", LocalAssets.manager, FontWeight.Normal), + Font("GeistMono-Medium.ttf", LocalAssets.manager, FontWeight.Medium), + Font("GeistMono-SemiBold.ttf", LocalAssets.manager, FontWeight.SemiBold), + ) +} + +/** App-wide AssetManager for font families declared at top level. */ +object LocalAssets { + lateinit var manager: android.content.res.AssetManager +} + +private fun TextStyle.geist() = copy(fontFamily = Geist) + +private fun zeronTypography(): Typography { + val base = Typography() + fun TextStyle.w(weight: FontWeight, tracking: Float? = null) = + geist().copy(fontWeight = weight, letterSpacing = tracking?.sp ?: letterSpacing) + return Typography( + displayLarge = base.displayLarge.w(FontWeight.SemiBold, -1.5f), + displayMedium = base.displayMedium.w(FontWeight.SemiBold, -1.2f), + displaySmall = base.displaySmall.w(FontWeight.SemiBold, -1f), + headlineLarge = base.headlineLarge.w(FontWeight.SemiBold, -0.6f), + headlineMedium = base.headlineMedium.w(FontWeight.SemiBold, -0.4f), + headlineSmall = base.headlineSmall.w(FontWeight.SemiBold, -0.3f), + titleLarge = base.titleLarge.w(FontWeight.SemiBold, -0.2f), + titleMedium = base.titleMedium.w(FontWeight.Medium), + titleSmall = base.titleSmall.w(FontWeight.Medium), + bodyLarge = base.bodyLarge.geist().copy(fontSize = 16.5.sp, lineHeight = 24.sp, letterSpacing = 0.sp), + bodyMedium = base.bodyMedium.geist().copy(letterSpacing = 0.sp), + bodySmall = base.bodySmall.geist().copy(letterSpacing = 0.sp), + labelLarge = base.labelLarge.w(FontWeight.Medium, 0f), + labelMedium = base.labelMedium.w(FontWeight.Medium, 0f), + labelSmall = base.labelSmall.w(FontWeight.Medium, 0f), + displayLargeEmphasized = base.displayLarge.w(FontWeight.Bold, -1.5f), + displayMediumEmphasized = base.displayMedium.w(FontWeight.Bold, -1.2f), + displaySmallEmphasized = base.displaySmall.w(FontWeight.Bold, -1f), + headlineLargeEmphasized = base.headlineLarge.w(FontWeight.Bold, -0.6f), + headlineMediumEmphasized = base.headlineMedium.w(FontWeight.Bold, -0.4f), + headlineSmallEmphasized = base.headlineSmall.w(FontWeight.Bold, -0.3f), + titleLargeEmphasized = base.titleLarge.w(FontWeight.Bold, -0.2f), + titleMediumEmphasized = base.titleMedium.w(FontWeight.SemiBold), + titleSmallEmphasized = base.titleSmall.w(FontWeight.SemiBold), + bodyLargeEmphasized = base.bodyLarge.w(FontWeight.Medium, 0f).copy(fontSize = 16.5.sp, lineHeight = 24.sp), + bodyMediumEmphasized = base.bodyMedium.w(FontWeight.Medium, 0f), + bodySmallEmphasized = base.bodySmall.w(FontWeight.Medium, 0f), + labelLargeEmphasized = base.labelLarge.w(FontWeight.SemiBold, 0f), + labelMediumEmphasized = base.labelMedium.w(FontWeight.SemiBold, 0f), + labelSmallEmphasized = base.labelSmall.w(FontWeight.SemiBold, 0f), + ) +} + +enum class ThemeMode { System, Light, Dark } + +data class Appearance(val mode: ThemeMode = ThemeMode.System, val dynamicColor: Boolean = false) + +/** Whether the resolved theme is dark (the transcript palette follows it). */ +val LocalDarkTheme = staticCompositionLocalOf { false } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ZeronTheme(appearance: Appearance = Appearance(), content: @Composable () -> Unit) { + val dark = when (appearance.mode) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val context = LocalContext.current + val scheme: ColorScheme = when { + appearance.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + dark -> ZeronDark + else -> ZeronLight + } + val typography = remember { zeronTypography() } + CompositionLocalProvider(LocalDarkTheme provides dark) { + MaterialExpressiveTheme( + colorScheme = scheme, + motionScheme = MotionScheme.expressive(), + typography = typography, + content = content, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/design/WallpaperHero.kt b/apps/android/app/src/main/java/sh/zeron/android/design/WallpaperHero.kt new file mode 100644 index 0000000000..01a7cd210b --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/design/WallpaperHero.kt @@ -0,0 +1,84 @@ +package sh.zeron.android.design + +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import sh.zeron.android.core.WallpaperStore +import kotlin.math.max +import kotlin.math.min + +/** + * The wallpaper hero: aspect-filled artwork at the top of a page that fades + * out downward (alpha, never a tinted overlay — the page shows through), at + * the opacity the contrast guard allows. An optional cutout (the new-session + * composer, in root coordinates) softens the artwork behind it. + */ +@Composable +fun WallpaperHero(store: WallpaperStore, modifier: Modifier = Modifier, scrollFade: () -> Float = { 1f }, cutout: () -> Rect? = { null }) { + val state by store.state.collectAsState() + val dark = LocalDarkTheme.current + val render by produceState(null, state, dark) { value = store.render(dark) } + val r = render ?: return + // Artwork arriving late fades in (desktop: 120 ms). + val appear = remember(r) { Animatable(0f) } + LaunchedEffect(r) { appear.animateTo(1f, tween(120)) } + BoxWithConstraints(modifier.fillMaxWidth()) { + // Desktop hero: 72% of the viewport, at most 760dp. + val height = min(maxHeight.value * 0.72f, 760f).dp + Box( + Modifier + .fillMaxWidth() + .height(height) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + alpha = r.opacity * appear.value * scrollFade().coerceIn(0f, 1f) + } + .drawWithContent { + drawContent() + // Smoothstep fade from opaque at the top to clear at the bottom. + val stops = (0..24).map { i -> + val t = i / 24f + t to Color.Black.copy(alpha = 1f - t * t * (3 - 2 * t)) + }.toTypedArray() + drawRect(Brush.verticalGradient(*stops), blendMode = BlendMode.DstIn) + cutout()?.let { c -> + val feather = min(280f, max(120f, size.height / density * 0.52f)) / 3f * density + drawIntoCanvas { canvas -> + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.argb(128, 0, 0, 0) + xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.DST_OUT) + maskFilter = android.graphics.BlurMaskFilter(feather, android.graphics.BlurMaskFilter.Blur.NORMAL) + } + val inset = 8 * density + canvas.nativeCanvas.drawRoundRect(c.left - inset, c.top - inset, c.right + inset, c.bottom + inset, 26 * density, 26 * density, paint) + } + } + }, + ) { + Image(r.image, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/design/ZeronIcons.kt b/apps/android/app/src/main/java/sh/zeron/android/design/ZeronIcons.kt new file mode 100644 index 0000000000..aea040bb55 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/design/ZeronIcons.kt @@ -0,0 +1,74 @@ +package sh.zeron.android.design + +import androidx.annotation.DrawableRes +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import sh.zeron.android.R + +/** + * Zeron's icon set — the same glyphs the desktop (crates/ui/assets/icons, + * Solar Linear + Zeron's hand-drawn additions) and iOS draw, compiled to + * VectorDrawables at build time by scripts/android/svg2vd.py. + */ +object ZIcons { + val NewSession = R.drawable.zi_pen_new_square + val Search = R.drawable.zi_magnifer + val TabSessions = R.drawable.zi_tab_chat + val TabSettings = R.drawable.zi_tab_settings + val Back = R.drawable.zi_arrow_left + val More = R.drawable.zi_more_horizontal + val Close = R.drawable.zi_close + val Plus = R.drawable.zi_plus + val Attach = R.drawable.zi_paperclip + val Send = R.drawable.zi_arrow_up + val Stop = R.drawable.zi_stop + val Check = R.drawable.zi_check + val ChevronDown = R.drawable.zi_alt_arrow_down + val ChevronUp = R.drawable.zi_alt_arrow_up + val ChevronRight = R.drawable.zi_alt_arrow_right + val ArrowDown = R.drawable.zi_arrow_down + val Branch = R.drawable.zi_git_branch + val PullRequest = R.drawable.zi_pull_request + val Effort = R.drawable.zi_tuning + val Queue = R.drawable.zi_clock_circle + val Context = R.drawable.zi_widget + val Warning = R.drawable.zi_danger_triangle + val Pin = R.drawable.zi_pin + val Archive = R.drawable.zi_archive_minimalistic + val Unarchive = R.drawable.zi_archive_up_minimalistic + val Rename = R.drawable.zi_pen + val Copy = R.drawable.zi_copy + val Delete = R.drawable.zi_trash_bin_minimalistic + val Home = R.drawable.zi_home + val Folder = R.drawable.zi_folder + val Project = R.drawable.zi_folder_with_files + val Laptop = R.drawable.zi_laptop + val Monitor = R.drawable.zi_monitor + val Phone = R.drawable.zi_smartphone + val Server = R.drawable.zi_remote_server + val Sun = R.drawable.zi_sun + val Moon = R.drawable.zi_moon + val Magic = R.drawable.zi_magic_stick_3 + val Info = R.drawable.zi_info_circle + val Logout = R.drawable.zi_logout_2 + val Offline = R.drawable.zi_wifi_off + val Chat = R.drawable.zi_chat_round_line + val Refresh = R.drawable.zi_refresh + val Text = R.drawable.zi_document + val Image = R.drawable.zi_file_image + val Link = R.drawable.zi_arrow_up_right +} + +@Composable +fun ZIcon( + @DrawableRes icon: Int, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + Icon(painterResource(icon), contentDescription, modifier, tint) +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/transcript/RowModel.kt b/apps/android/app/src/main/java/sh/zeron/android/transcript/RowModel.kt new file mode 100644 index 0000000000..971103bf8a --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/transcript/RowModel.kt @@ -0,0 +1,194 @@ +package sh.zeron.android.transcript + +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.graphics.Shader +import sh.zeron.android.core.Fonts +import sh.zeron.android.design.TranscriptPalette +import uniffi.zeron_core.BoxStyle +import uniffi.zeron_core.Decoration +import uniffi.zeron_core.FadeEdge +import uniffi.zeron_core.RowDisplay +import uniffi.zeron_core.StyleDesc + +/** + * Style id → Paint for one transcript (ids are per layout view). Paints are + * sized in pixels for the current density; main thread only (color mutates + * per run while painting). + */ +class StyleFonts { + private val paints = HashMap() + private var density = 0f + var count = 0 + private set + + fun update(styles: List, density: Float) { + if (density != this.density) { + paints.clear() + this.density = density + } + for (s in styles) { + val id = s.id.toInt() + if (id !in paints) paints[id] = Fonts.paint(s.face, s.size * density, s.ligatures) + } + count = styles.size + } + + operator fun get(id: UShort): Paint? = paints[id.toInt()] +} + +/** + * A display list plus everything derived from it once: runs, boxes and fades + * grouped by layer (0 = the row canvas, n = scroller n-1). + */ +class RowModel(val display: RowDisplay) { + val layers = display.scrollers.size + 1 + val runsByLayer: Array + val boxesByLayer: Array + /** Overflow fades per layer, each with the runs painted through it. */ + val fadesByLayer: Array>> + private val faded: BooleanArray + + init { + val runs = Array(layers) { ArrayList() } + val boxes = Array(layers) { ArrayList() } + display.runs.forEachIndexed { i, r -> runs[r.scroller?.let { it.toInt() + 1 } ?: 0].add(i) } + display.boxes.forEachIndexed { i, b -> boxes[b.scroller?.let { it.toInt() + 1 } ?: 0].add(i) } + val fades = Array(layers) { ArrayList>() } + val masked = BooleanArray(display.runs.size) + display.fades.forEachIndexed { fi, f -> + val layer = f.scroller?.let { it.toInt() + 1 } ?: 0 + val under = runs[layer].filter { i -> + val r = display.runs[i] + !masked[i] && r.baseline > f.y && r.baseline <= f.y + f.h + 0.5f && + (f.edge == FadeEdge.BOTTOM || r.x < f.x + f.w) + } + under.forEach { masked[it] = true } + fades[layer].add(fi to under.toIntArray()) + } + runsByLayer = Array(layers) { runs[it].toIntArray() } + boxesByLayer = Array(layers) { boxes[it].toIntArray() } + fadesByLayer = Array(layers) { fades[it] } + faded = masked + } + + /** Which runs to paint: `veilFrom` splits a streaming row into settled and fresh text. */ + sealed interface Pass { + data object All : Pass + /** Only text in `[from, to)` (UTF-16), clipped at glyph edges; no boxes or fades. */ + data class Range(val from: Int, val to: Int) : Pass + /** Boxes and overflow fades only (the text is drawn by ranges). */ + data object Chrome : Pass + } + + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val eraser = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) } + private val rect = RectF() + + /** Paint one layer in its own coordinates (dp × `d` = px). */ + fun draw(canvas: Canvas, layer: Int, d: Float, fonts: StyleFonts, palette: TranscriptPalette, pass: Pass = Pass.All) { + val hairline = 1f + if (pass !is Pass.Range) { + for (i in boxesByLayer[layer]) { + val b = display.boxes[i] + rect.set(b.x * d, b.y * d, (b.x + b.w) * d, (b.y + b.h) * d) + val r = b.radius * d + when (b.style) { + BoxStyle.FILL -> { + fill.color = palette[b.color] + canvas.drawRoundRect(rect, r, r, fill) + } + BoxStyle.HAIRLINE -> { + stroke.color = palette[b.color] + stroke.strokeWidth = hairline + rect.inset(hairline / 2, hairline / 2) + val rr = maxOf(0f, r - hairline / 2) + canvas.drawRoundRect(rect, rr, rr, stroke) + } + } + } + } + for (i in runsByLayer[layer]) { + if (faded[i]) continue + val run = display.runs[i] + val start = run.start.toInt() + val end = start + run.len.toInt() + val (from, to) = when (pass) { + Pass.All -> 0 to Int.MAX_VALUE + is Pass.Range -> pass.from to pass.to + Pass.Chrome -> break + } + if (end <= from || start >= to) continue + var clipped = false + if (start < from || end > to) { + val paint = fonts[run.style] + fun dx(at: Int) = paint?.getRunAdvance(display.text, start, end, start, end, false, at) ?: 0f + val left = if (start < from) run.x * d + dx(from) else -1e5f + val right = if (end > to) run.x * d + dx(to) else 1e5f + canvas.save() + canvas.clipRect(left, -1e5f, right, 1e5f) + clipped = true + } + drawRun(canvas, i, d, fonts, palette) + if (clipped) canvas.restore() + } + if (pass !is Pass.Range) drawFades(canvas, layer, d, fonts, palette) + } + + private fun drawRun(canvas: Canvas, i: Int, d: Float, fonts: StyleFonts, palette: TranscriptPalette, color: Int? = null) { + val run = display.runs[i] + val paint = fonts[run.style] ?: return + val start = run.start.toInt() + val end = start + run.len.toInt() + if (end > display.text.length) return + paint.color = color ?: palette[run.color] + canvas.drawText(display.text, start, end, run.x * d, run.baseline * d, paint) + if (run.decoration != Decoration.NONE) { + val y = if (run.decoration == Decoration.UNDERLINE) run.baseline + 2f else run.baseline - 5f + fill.color = paint.color + canvas.drawRect(run.x * d, y * d, (run.x + run.width) * d, y * d + maxOf(1f, d * 0.5f), fill) + } + } + + /** Canvas-layer runs whose baseline sits inside the band, in one color (shimmer). */ + fun drawRunsIn(canvas: Canvas, x: Float, y: Float, w: Float, h: Float, color: Int, d: Float, fonts: StyleFonts, palette: TranscriptPalette) { + for (i in runsByLayer[0]) { + val r = display.runs[i] + if (r.baseline <= y || r.baseline > y + h + 2 || r.x >= x + w) continue + drawRun(canvas, i, d, fonts, palette, color) + } + } + + /** Runs under a fade are painted into a layer, then erased along a ramp. */ + private fun drawFades(canvas: Canvas, layer: Int, d: Float, fonts: StyleFonts, palette: TranscriptPalette) { + for ((fi, runs) in fadesByLayer[layer]) { + if (runs.isEmpty()) continue + val f = display.fades[fi] + val x0 = f.x * d + val x1 = (f.x + f.w) * d + val y0 = f.y * d + val y1 = (f.y + f.h) * d + val pad = 40 * d + val save = canvas.save() + if (f.edge == FadeEdge.TRAILING) canvas.clipRect(-1e5f, y0 - pad, x1, y1 + pad) + canvas.saveLayer(-1e5f, y0 - pad * 2, 1e5f, y1 + pad * 2, null) + for (i in runs) drawRun(canvas, i, d, fonts, palette) + when (f.edge) { + FadeEdge.TRAILING -> { + eraser.shader = LinearGradient(x0, 0f, x1, 0f, 0x00000000, 0xFF000000.toInt(), Shader.TileMode.CLAMP) + canvas.drawRect(x0, y0 - pad, x1, y1 + pad, eraser) + } + FadeEdge.BOTTOM -> { + eraser.shader = LinearGradient(0f, y0, 0f, y1, 0x00000000, 0xEB000000.toInt(), Shader.TileMode.CLAMP) + canvas.drawRect(x0 - pad, y0, x1 + pad, y1 + pad, eraser) + } + } + canvas.restoreToCount(save) + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/transcript/Transcript.kt b/apps/android/app/src/main/java/sh/zeron/android/transcript/Transcript.kt new file mode 100644 index 0000000000..2be4a23db7 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/transcript/Transcript.kt @@ -0,0 +1,391 @@ +package sh.zeron.android.transcript + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.FileCopy +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import sh.zeron.android.design.LocalDarkTheme +import sh.zeron.android.design.ZIcons +import sh.zeron.android.design.TranscriptPalette +import uniffi.zeron_core.LayoutFrame +import uniffi.zeron_core.RowPlacement +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.pow + +/** What the transcript asks of its host (links, images, text sheets). */ +class TranscriptActions( + val openUrl: (String) -> Unit, + val loadImage: suspend (String) -> androidx.compose.ui.graphics.ImageBitmap?, + val showText: (title: String, text: String, mono: Boolean) -> Unit, +) + +private const val OVERSCAN = 700f +private const val BAND = 256f + +/** + * Virtualized transcript. Only rows intersecting the viewport (plus overscan) + * are composed; each paints its Rust display list on a canvas at the exact + * Rust coordinates, so measurement and rendering can't disagree. + */ +@Composable +fun Transcript(state: TranscriptState, actions: TranscriptActions, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val d = density.density + val scheme = MaterialTheme.colorScheme + val dark = LocalDarkTheme.current + val palette = remember(scheme, dark) { TranscriptPalette(dark, scheme) } + val interactions = remember { MutableInteractionSource() } + val scrollable = rememberScrollableState { px -> -state.scrollBy(-px / d) * d } + val overscroll = rememberOverscrollEffect() + + LaunchedEffect(interactions) { + interactions.interactions.collect { i -> + when (i) { + is DragInteraction.Start -> { + state.dragging = true + state.released() + } + is DragInteraction.Stop, is DragInteraction.Cancel -> { + state.dragging = false + if (state.distanceFromBottom < 70f) state.following = true + } + } + } + } + + // Follow spring: a critically damped approach to the tail, run only + // while there's distance to cover (idle transcripts don't tick). + LaunchedEffect(state) { + snapshotFlow { state.following && !state.dragging && abs(state.maxOffset - state.offset) > 0.5f } + .distinctUntilChanged() + .collectLatest { chasing -> + if (!chasing) return@collectLatest + var last = withFrameNanos { it } + while (true) { + val now = withFrameNanos { it } + val dt = min(1f / 30f, (now - last) / 1e9f) + last = now + val target = state.maxOffset + val delta = target - state.offset + if (abs(delta) < 0.5f) { + state.offset = target + break + } + state.offset += if (state.runwayActive) { + // Runway glide: the desktop's frame-rate-independent ease-out + // (15% of the remaining glide per 60 fps frame). + delta * (1f - 0.85f.pow(min(8f, dt * 60f))) + } else { + // Critically damped: the tail settles into place. + delta * (1f - exp(-dt * 16f)) + } + } + } + } + + BoxWithConstraints( + modifier + .clipToBounds() + .overscroll(overscroll) + .scrollable( + scrollable, + Orientation.Vertical, + overscrollEffect = overscroll, + interactionSource = interactions, + ), + ) { + val widthDp = maxWidth.value + val heightDp = maxHeight.value + state.setViewport(widthDp, heightDp, density.fontScale) + val frame = state.frame ?: return@BoxWithConstraints + if (frame.styleCount().toInt() != state.fonts.count) state.fonts.update(frame.styles(), d) + + // The realized band moves in coarse steps: scrolling within a band + // only re-places rows, it never recomposes. + val band by remember(state) { + derivedStateOf { floor((state.offset - OVERSCAN) / BAND) * BAND } + } + val motion = state.motion + val progress = remember { Animatable(1f) } + LaunchedEffect(motion?.id) { + if (motion == null) return@LaunchedEffect + progress.snapTo(0f) + progress.animateTo( + 1f, + tween(motion.durationMs, easing = if (motion.expo) CubicBezierEasing(0.16f, 1f, 0.3f, 1f) else FastOutSlowInEasing), + ) + } + val placements = remember(frame, band, heightDp) { + frame.rowsIn(band, band + heightDp + OVERSCAN * 2 + BAND) + } + Layout( + content = { + for (p in placements) { + key(p.key) { RowHost(state, frame, p, palette, actions) } + } + }, + ) { measurables, constraints -> + val w = constraints.maxWidth + // Mid-motion, rows interpolate from the previous frame's placement. + val t = progress.value + val from = if (t < 1f) motion?.from else null + fun lerp(a: Float, b: Float) = a + (b - a) * t + val placeables = measurables.mapIndexed { i, m -> + val p = placements[i] + val h = from?.get(p.key)?.let { lerp(it.second, p.height) } ?: p.height + m.measure(Constraints.fixed(w, (h * d).roundToInt().coerceAtLeast(0))) + } + layout(w, constraints.maxHeight) { + val off = state.offset + placeables.forEachIndexed { i, pl -> + val p = placements[i] + val y = from?.get(p.key)?.let { lerp(it.first, p.y) } ?: p.y + pl.placeRelative(0, ((y - off) * d).roundToInt()) + } + } + } + } +} + +@Composable +private fun RowHost( + state: TranscriptState, + frame: LayoutFrame, + p: RowPlacement, + palette: TranscriptPalette, + actions: TranscriptActions, +) { + val model = remember(p.key, p.version, frame.width()) { state.model(frame, p.index, p.key, p.version) } ?: return + val d = LocalDensity.current.density + val fonts = state.fonts + + // Streaming veil: each appended chunk fades in on its own clock, so a + // new token never restarts (or pops) the ones still fading. Chunks are + // recorded during composition — before the new text is ever drawn. + val veil = remember(p.key) { Veil() } + remember(model) { veil.grow(model); Unit } + val clock = remember(p.key) { mutableLongStateOf(0L) } + LaunchedEffect(model) { + while (veil.active(System.nanoTime())) { + withFrameNanos { clock.longValue = it } + } + clock.longValue = System.nanoTime() + } + + // Rows that arrive after the first frame fade in. + val appear = remember(p.key) { Animatable(if (state.knownKeys.add(p.key) && state.settled) 0f else 1f) } + LaunchedEffect(p.key) { if (appear.value < 1f) appear.animateTo(1f, tween(280)) } + + var menuAt by remember { mutableStateOf(null) } + val haptics = LocalHapticFeedback.current + + Box( + Modifier + .clipToBounds() + .graphicsLayer { alpha = appear.value } + .drawBehind { + clock.longValue // redraw on every veil frame + drawIntoCanvas { c -> veil.draw(c.nativeCanvas, model, d, fonts, palette, System.nanoTime()) } + } + .pointerTaps(model, d, actions, onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menuAt = it + }), + ) { + model.display.scrollers.forEachIndexed { i, s -> + val scroll = rememberScrollState() + Box( + Modifier + .offset(s.x.dp, s.y.dp) + .size(s.w.dp, s.h.dp) + .horizontalScroll(scroll), + ) { + Box( + Modifier + .size(s.contentWidth.dp, s.h.dp) + .drawBehind { drawIntoCanvas { model.draw(it.nativeCanvas, i + 1, d, fonts, palette) } }, + ) { + RowWidgets(state, model, scroller = i.toUInt(), palette, actions) + } + } + } + RowWidgets(state, model, scroller = null, palette, actions) + menuAt?.let { at -> RowMenu(state, frame, p, model, at, actions) { menuAt = null } } + } +} + +/** Link taps (row coordinates) and the long-press menu. */ +private fun Modifier.pointerTaps(model: RowModel, d: Float, actions: TranscriptActions, onLongPress: (Offset) -> Unit) = + pointerInput(model) { + detectTapGestures( + onTap = { pos -> + val x = pos.x / d + val y = pos.y / d + model.display.links.firstOrNull { l -> + l.scroller == null && x >= l.x - 4 && x <= l.x + l.w + 4 && y >= l.y - 2 && y <= l.y + l.h + 2 + }?.let { actions.openUrl(it.url) } + }, + onLongPress = onLongPress, + ) + } + +@Composable +private fun RowMenu( + state: TranscriptState, + frame: LayoutFrame, + p: RowPlacement, + model: RowModel, + at: Offset, + actions: TranscriptActions, + dismiss: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val density = LocalDensity.current + val block = model.display.copyText + val message = remember(frame, p.index) { frame.messageText(p.index) } + Box(Modifier.offset(with(density) { at.x.toDp() }, with(density) { at.y.toDp() })) { + val selectable = message ?: block + sh.zeron.android.ui.ActionMenu( + true, + dismiss, + listOfNotNull( + if (block.isNotEmpty()) sh.zeron.android.ui.MenuAction("Copy", ZIcons.Copy) { clipboard.setText(AnnotatedString(block)) } else null, + if (!message.isNullOrEmpty() && message != block) sh.zeron.android.ui.MenuAction("Copy message", ZIcons.Chat) { clipboard.setText(AnnotatedString(message)) } else null, + if (selectable.isNotEmpty()) sh.zeron.android.ui.MenuAction("Select text", ZIcons.Text) { actions.showText("Select text", selectable, false) } else null, + ), + ) + } +} + +/** + * Fade-in bookkeeping for one streaming row: each inserted chunk of text + * (UTF-16 `[from, to)`) with its start time. Chunks come from a prefix + + * suffix diff, not a prefix check: list rows carry their bullet after the + * item text, so streamed words land *before* the row's tail. + */ +private class Veil { + private class Chunk(var from: Int, var to: Int, val started: Long) + + private var last: RowModel? = null + private val chunks = ArrayList() + + fun grow(model: RowModel) { + val prev = last + last = model + if (prev == null || prev === model) return + val old = prev.display.text + val new = model.display.text + val delta = new.length - old.length + if (delta <= 0) { + chunks.clear() + return + } + var p = 0 + val max = old.length + while (p < max && old[p] == new[p]) p++ + var sfx = 0 + while (sfx < max - p && old[old.length - 1 - sfx] == new[new.length - 1 - sfx]) sfx++ + if (new.length - p - sfx != delta) { + // More than an insertion (the block was re-flowed): show it as is. + chunks.clear() + return + } + for (c in chunks) { + if (c.from >= p) { + c.from += delta + c.to += delta + } + } + chunks.add(Chunk(p, p + delta, System.nanoTime())) + chunks.sortBy { it.from } + } + + fun active(now: Long): Boolean { + chunks.removeAll { now - it.started >= DURATION } + return chunks.isNotEmpty() + } + + fun draw(canvas: android.graphics.Canvas, model: RowModel, d: Float, fonts: StyleFonts, palette: sh.zeron.android.design.TranscriptPalette, now: Long) { + val live = chunks.filter { now - it.started < DURATION } + if (live.isEmpty()) { + model.draw(canvas, 0, d, fonts, palette) + return + } + model.draw(canvas, 0, d, fonts, palette, RowModel.Pass.Chrome) + // Settled text: everything between the fading chunks. + var cursor = 0 + for (c in live) { + if (c.from > cursor) model.draw(canvas, 0, d, fonts, palette, RowModel.Pass.Range(cursor, c.from)) + cursor = maxOf(cursor, c.to) + } + model.draw(canvas, 0, d, fonts, palette, RowModel.Pass.Range(cursor, Int.MAX_VALUE)) + for (c in live) { + val t = ((now - c.started).toFloat() / DURATION).coerceIn(0f, 1f) + val alpha = 1f - (1f - t) * (1f - t) // ease-out + val save = canvas.saveLayerAlpha(null, (alpha * 255).toInt()) + model.draw(canvas, 0, d, fonts, palette, RowModel.Pass.Range(c.from, c.to)) + canvas.restoreToCount(save) + } + } + + companion object { + const val DURATION = 220_000_000L + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/transcript/TranscriptState.kt b/apps/android/app/src/main/java/sh/zeron/android/transcript/TranscriptState.kt new file mode 100644 index 0000000000..442a81fbef --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/transcript/TranscriptState.kt @@ -0,0 +1,235 @@ +package sh.zeron.android.transcript + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import sh.zeron.android.core.TextEngine +import uniffi.zeron_core.LayoutFrame +import uniffi.zeron_core.LayoutListener +import uniffi.zeron_core.RowKind +import uniffi.zeron_core.TranscriptView +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.max + +/** + * One transcript's layout engine plus the viewport over it. Rust owns + * geometry: each [LayoutFrame] gives exact row offsets, so this only tracks + * the scroll offset (dp) and keeps the user's place across frames — anchored + * to a row, or following the tail. + */ +@Stable +class TranscriptState { + private val main = Handler(Looper.getMainLooper()) + private val pending = AtomicBoolean(false) + + val engine: TranscriptView = TranscriptView(TextEngine.shared, object : LayoutListener { + // Layout thread: coalesce to one pull per main-loop turn. + override fun frameReady(revision: ULong) { + if (pending.compareAndSet(false, true)) main.post { + pending.set(false) + if (!closed) apply(engine.frame()) + } + } + }) + + var frame by mutableStateOf(null) + private set + /** Scroll offset in dp (0 = top of the content). */ + var offset by mutableFloatStateOf(0f) + var viewport by mutableFloatStateOf(0f) + /** Breathing room below the last row, on top of the frame's own (dp). */ + var bottomInset by mutableFloatStateOf(12f) + var following by mutableStateOf(true) + var dragging by mutableStateOf(false) + val fonts = StyleFonts() + /** Rows present before a frame arrived don't fade in. */ + internal val knownKeys = HashSet() + internal var settled = false + + private val cache = object : LinkedHashMap, RowModel>(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry, RowModel>?) = size > 400 + } + + private var closed = false + private var width = 0f + private var scale = 0f + + /** Attachment upload progress (rings on `pending://` thumbnails). */ + var uploadProgress by mutableStateOf(null) + + // ── Runway (desktop transcript.rs `OwnTurnAnchor`) ───────────────────── + // + // On an immediate send the prompt glides to the top of the viewport and + // the content is held at least one viewport tall below it, so the reply + // streams into reserved space without the view moving on every token. + // Once the reply fills it, the runway retires and the ordinary follow + // takes over; a drag releases the hold (the space stays as scroll room). + private class OwnTurn(val key: ULong?, val before: Set) + private var ownTurn by mutableStateOf(null) + private var queuedTurn: Set? = null + + /** Reserve the reply's space below the prompt about to be sent. */ + fun beginOwnTurn() { + queuedTurn = null + ownTurn = OwnTurn(null, recentUserKeys()) + following = true + } + + /** A message queued behind the live turn takes the runway once its bubble lands. */ + fun expectQueuedTurn() { + if (queuedTurn == null) queuedTurn = recentUserKeys() + following = true + } + + /** The newest user row not in `before`, near the tail. */ + private fun newUserRow(f: LayoutFrame, before: Set): ULong? { + val n = f.rowCount().toInt() + for (i in (n - 1) downTo max(0, n - 12)) { + val p = f.placement(i.toUInt()) ?: continue + if (p.kind == RowKind.USER && p.key !in before) return p.key + } + return null + } + + /** Content height the runway holds, or null when it isn't holding. */ + private val runwayHeight: Float? + get() { + val turn = ownTurn ?: return null + val f = frame ?: return null + val key = turn.key ?: return null + val p = f.indexOf(key)?.let { f.placement(it) } ?: return null + val natural = f.totalHeight() + bottomInset + val minimum = p.y + viewport + return if (natural >= minimum - 0.5f) null else minimum + } + + /** The runway is gliding the prompt up (ease-out instead of the settle spring). */ + val runwayActive: Boolean get() = runwayHeight != null + + val contentHeight: Float get() = max((frame?.totalHeight() ?: 0f) + bottomInset, runwayHeight ?: 0f) + val maxOffset: Float get() = max(0f, contentHeight - viewport) + val distanceFromBottom: Float get() = maxOffset - offset + + fun setViewport(widthDp: Float, heightDp: Float, textScale: Float) { + viewport = heightDp + if (widthDp != width || textScale != scale) { + width = widthDp + scale = textScale + engine.setViewport(widthDp, textScale) + } + } + + fun apply(new: LayoutFrame) { + val old = frame + val first = old == null || old.rowCount() == 0u + var anchor: Pair? = null + if (!following && old != null) { + old.indexAt(max(0f, offset))?.let { i -> + old.placement(i)?.let { p -> anchor = p.key to (offset - p.y) } + } + } + queuedTurn?.let { before -> + newUserRow(new, before)?.let { key -> + queuedTurn = null + ownTurn = OwnTurn(key, before) + following = true + } + } + ownTurn?.let { turn -> if (turn.key == null) newUserRow(new, turn.before)?.let { ownTurn = OwnTurn(it, turn.before) } } + motion = motionFor(old, new) + frame = new + if (first && new.rowCount() > 0u) { + for (i in 0u until new.rowCount()) new.placement(i)?.let { knownKeys.add(it.key) } + offset = maxOffset + settled = true + } else if (!following) { + anchor?.let { (key, delta) -> + new.indexOf(key)?.let { i -> new.placement(i)?.let { p -> offset = (p.y + delta).coerceIn(0f, maxOffset) } } + } + } + } + + // ── Row motion ──────────────────────────────────────────────────────── + // + // Fold toggles tween 140 ms ease-out; a visible tool group that grew (a + // new call arrived) reveals over 360 ms expo — the desktop timings. Rows + // interpolate from where the previous frame placed them. + + class Motion(val id: Int, val from: Map>, val durationMs: Int, val expo: Boolean) + + var motion by mutableStateOf(null) + private set + private var motionId = 0 + private var pendingFold: ULong? = null + + private fun motionFor(old: LayoutFrame?, new: LayoutFrame): Motion? { + if (old == null || old.rowCount() == 0u || old.width() != new.width()) return null + fun height(f: LayoutFrame, key: ULong) = f.indexOf(key)?.let { f.placement(it)?.height } + val band = old.rowsIn(offset - viewport, offset + viewport * 2) + val fold = pendingFold + val kind = when { + fold != null && height(old, fold) != null && height(old, fold) != height(new, fold) -> { + pendingFold = null + 140 to false + } + band.any { it.kind == RowKind.TOOLS && (height(new, it.key) ?: 0f) > it.height } -> 360 to true + else -> return null + } + return Motion(++motionId, band.associate { it.key to (it.y to it.height) }, kind.first, kind.second) + } + + /** The display model for a row at the frame's width (cached per version). */ + fun model(frame: LayoutFrame, index: UInt, key: ULong, version: ULong): RowModel? { + val k = Triple(key, version, frame.width()) + cache[k]?.let { return it } + val display = frame.display(index) ?: return null + return RowModel(display).also { cache[k] = it } + } + + /** User scroll by `delta` dp (positive = toward the tail). Returns the consumed amount. */ + fun scrollBy(delta: Float): Float { + val before = offset + offset = (offset + delta).coerceIn(0f, maxOffset) + // Momentum carrying the list back into the last 70dp re-latches follow. + if (!following && !dragging && delta > 0 && distanceFromBottom < 70f) following = true + return offset - before + } + + fun scrollToBottom() { + following = true + } + + /** A drag hands control to the user (the runway's space stays as scroll room). */ + fun released() { + following = false + } + + fun toggle(key: ULong) { + pendingFold = key + engine.toggle(key) + } + + fun toggleDetail(row: ULong, detail: ULong, open: Boolean) { + pendingFold = row + engine.toggleDetail(row, detail, open) + } + + /** Newest user row keys (the runway looks for a new one after a send). */ + fun recentUserKeys(): Set { + val f = frame ?: return emptySet() + val n = f.rowCount().toInt() + val out = HashSet() + for (i in (n - 1) downTo max(0, n - 24)) f.placement(i.toUInt())?.let { if (it.kind == RowKind.USER) out.add(it.key) } + return out + } + + fun close() { + closed = true + engine.shutdown() + engine.close() + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/transcript/Widgets.kt b/apps/android/app/src/main/java/sh/zeron/android/transcript/Widgets.kt new file mode 100644 index 0000000000..9261b29a71 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/transcript/Widgets.kt @@ -0,0 +1,350 @@ +package sh.zeron.android.transcript + +import android.graphics.BitmapFactory +import android.util.LruCache +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import sh.zeron.android.design.Geist +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import androidx.compose.foundation.border +import sh.zeron.android.design.TranscriptPalette +import uniffi.zeron_core.ColorRole +import uniffi.zeron_core.Widget +import uniffi.zeron_core.WidgetKind +import kotlin.math.max + +/** Native affordances Rust positioned in the row (or one of its scrollers). */ +@Composable +fun RowWidgets(state: TranscriptState, model: RowModel, scroller: UInt?, palette: TranscriptPalette, actions: TranscriptActions) { + val d = model.display + val live = d.widgets.any { it.kind is WidgetKind.Shimmer } + for (w in d.widgets) { + if (w.scroller != scroller) continue + Box(Modifier.offset(w.x.dp, w.y.dp).size(max(w.w, 0f).dp, max(w.h, 0f).dp)) { + Widget(state, model, w, palette, actions, live) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun Widget(state: TranscriptState, model: RowModel, w: Widget, palette: TranscriptPalette, actions: TranscriptActions, live: Boolean) { + val key = model.display.key + val haptics = LocalHapticFeedback.current + when (val kind = w.kind) { + WidgetKind.CopyCode -> CopyButton(w.payload ?: "", palette) + is WidgetKind.Disclosure -> Tap(if (kind.expanded) "Collapse" else "Expand") { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + state.toggle(key) + } + is WidgetKind.ToolStatus -> when { + kind.running -> CircularProgressIndicator(Modifier.fillMaxSize().padding(1.dp), strokeWidth = 1.5.dp, color = Color(palette[ColorRole.TEXT_TERTIARY])) + else -> ZIcon( + if (kind.failed) ZIcons.Close else ZIcons.Check, + null, + Modifier.fillMaxSize(), + tint = Color(palette[if (kind.failed) ColorRole.DANGER else ColorRole.TEXT_TERTIARY]), + ) + } + is WidgetKind.Image -> RemoteImage(kind.reference, w, actions) { state.uploadProgress } + WidgetKind.Spinner -> CircularProgressIndicator(Modifier.fillMaxSize().padding(1.dp), strokeWidth = 1.5.dp, color = Color(palette[ColorRole.TEXT_TERTIARY])) + is WidgetKind.Working -> WorkingIndicator(kind.sinceMs, kind.streaming, palette) + is WidgetKind.Detail -> Tap("${kind.title} details") { actions.showText(kind.title, w.payload ?: "", true) } + is WidgetKind.Icon -> AssetIcon(kind.name, Color(palette[kind.color])) + is WidgetKind.Chevron -> ZIcon( + ZIcons.ChevronRight, + null, + Modifier.fillMaxSize().rotate(if (kind.expanded) 90f else 0f), + tint = Color(palette[ColorRole.TEXT_TERTIARY]), + ) + is WidgetKind.ToolRail -> ToolRail(kind, Color(palette[ColorRole.TOOL_RAIL])) + is WidgetKind.ToolToggle -> Tap(if (kind.open) "Hide details" else "Show details") { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + state.toggleDetail(key, kind.detail, kind.open) + } + WidgetKind.Shimmer -> Shimmer(state, model, w, palette) + } +} + +@Composable +private fun Tap(label: String, onClick: () -> Unit) { + Box( + Modifier + .fillMaxSize() + .semantics { contentDescription = label } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), + ) +} + +@Composable +private fun CopyButton(payload: String, palette: TranscriptPalette) { + val clipboard = LocalClipboardManager.current + val haptics = LocalHapticFeedback.current + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(1400) + copied = false + } + } + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .semantics { contentDescription = "Copy code" } + .clickable { + clipboard.setText(AnnotatedString(payload)) + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + copied = true + }, + contentAlignment = Alignment.Center, + ) { + ZIcon( + if (copied) ZIcons.Check else ZIcons.Copy, + null, + Modifier.size(16.dp), + tint = if (copied) MaterialTheme.colorScheme.primary else Color(palette[ColorRole.TEXT_TERTIARY]), + ) + } +} + +/** Tail-of-turn indicator: the expressive loading shape + "Working…" and elapsed time. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun WorkingIndicator(sinceMs: Long?, streaming: Boolean, palette: TranscriptPalette) { + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(1000) + now = System.currentTimeMillis() + } + } + val secs = sinceMs?.let { max(0L, (now - it) / 1000) } ?: 0L + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + LoadingIndicator(Modifier.size(24.dp), color = MaterialTheme.colorScheme.primary) + Text( + buildAnnotatedString { + append(if (streaming) "Writing…" else "Working…") + if (secs > 0) withStyle(SpanStyle(color = Color(palette[ColorRole.TEXT_TERTIARY]))) { append(" " + elapsed(secs)) } + }, + color = Color(palette[ColorRole.TEXT_SECONDARY]), + fontFamily = Geist, + fontSize = 13.5.sp, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Medium), + ) + } +} + +fun elapsed(secs: Long): String = when { + secs < 60 -> "${secs}s" + secs < 3600 -> "${secs / 60}m ${secs % 60}s" + else -> "${secs / 3600}h ${(secs % 3600) / 60}m" +} + +/** Desktop tool/file icons (rasterized SVGs); unknown names draw nothing. */ +@Composable +private fun AssetIcon(name: String, tint: Color) { + val context = LocalContext.current + when (name) { + "square", "checkmark.square.fill" -> return TaskBox(name != "square", tint) + "arrow.triangle.branch" -> return ZIcon(ZIcons.Branch, null, Modifier.fillMaxSize(), tint = tint) + "exclamationmark.triangle" -> return ZIcon(ZIcons.Warning, null, Modifier.fillMaxSize(), tint = tint) + "questionmark.bubble" -> return ZIcon(ZIcons.Chat, null, Modifier.fillMaxSize(), tint = tint) + } + val image = remember(name) { IconAssets.load(context, name) } ?: return + Image( + image, + null, + Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + colorFilter = if (name.startsWith("tool-")) ColorFilter.tint(tint) else null, + ) +} + +/** A markdown task checkbox: a rounded square, filled with a check when done. */ +@Composable +private fun TaskBox(checked: Boolean, tint: Color) { + Box( + Modifier + .fillMaxSize() + .padding(2.dp) + .clip(RoundedCornerShape(5.dp)) + .then(if (checked) Modifier.background(tint) else Modifier.border(1.5.dp, tint, RoundedCornerShape(5.dp))), + contentAlignment = Alignment.Center, + ) { + if (checked) ZIcon(ZIcons.Check, null, Modifier.fillMaxSize().padding(1.dp), tint = MaterialTheme.colorScheme.onPrimary) + } +} + +object IconAssets { + private val cache = HashMap() + + fun load(context: android.content.Context, name: String): ImageBitmap? = cache.getOrPut(name) { + if (!name.startsWith("tool-") && !name.startsWith("fileicon-")) return@getOrPut null + runCatching { context.assets.open("icons/$name.png").use { BitmapFactory.decodeStream(it)?.asImageBitmap() } }.getOrNull() + } +} + +private val images = LruCache(48) + +@Composable +private fun RemoteImage(reference: String, w: Widget, actions: TranscriptActions, progress: () -> Double?) { + val image by produceState(images.get(reference), reference) { + if (value == null) value = actions.loadImage(reference)?.also { images.put(reference, it) } + } + val radius = if (minOf(w.w, w.h) > 120) 14.dp else 12.dp + Box(Modifier.fillMaxSize().clip(RoundedCornerShape(radius)), contentAlignment = Alignment.Center) { + image?.let { Image(it, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + ?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceContainerHigh)) + // Still being escorted to the host: a scrim and a wavy ring that fills with the transfer. + val p = if (reference.startsWith("pending://")) progress() else null + androidx.compose.animation.AnimatedVisibility(p != null, enter = fadeIn(), exit = fadeOut()) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.38f)), contentAlignment = Alignment.Center) { + val side = minOf(w.w, w.h) + CircularWavyProgressIndicator( + progress = { (p ?: 0.0).toFloat().coerceIn(0.02f, 1f) }, + modifier = Modifier.size(minOf(40f, side * 0.55f).dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + Text( + "${((p ?: 0.0).coerceIn(0.0, 1.0) * 100).toInt()}%", + color = Color.White, + fontSize = 10.sp, + style = MaterialTheme.typography.labelSmallEmphasized, + modifier = Modifier.semantics { contentDescription = "Uploading" }, + ) + } + } + } +} + +/** The activity rail of an expanded tool group: trunk, elbows, branches. */ +@Composable +private fun ToolRail(r: WidgetKind.ToolRail, color: Color) { + val d = LocalDensity.current.density + Canvas(Modifier.fillMaxSize()) { + val path = Path() + val x = r.trunkX * d + val bend = r.bend * d + for (i in r.tops.indices) { + val top = r.tops[i] * d + val mid = top + r.rowMid * d + path.moveTo(x, top) + path.lineTo(x, mid - bend) + path.quadraticTo(x, mid, x + bend, mid) + path.lineTo(r.branchEnd * d, mid) + if (i + 1 < r.tops.size) { + path.moveTo(x, mid - bend) + path.lineTo(x, top + r.heights[i] * d) + } + } + drawPath(path, color, style = Stroke(width = d)) + } +} + +/** Shimmer over the live group's title: its runs re-drawn brighter through a moving band. */ +@Composable +private fun Shimmer(state: TranscriptState, model: RowModel, w: Widget, palette: TranscriptPalette) { + val d = LocalDensity.current.density + val transition = rememberInfiniteTransition(label = "shimmer") + val phase by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(3400, easing = LinearEasing), RepeatMode.Restart), label = "sweep") + val bright = MaterialTheme.colorScheme.onSurface + Canvas(Modifier.fillMaxSize()) { + drawIntoCanvas { c -> + val canvas = c.nativeCanvas + val width = max(w.w, 1f) * d + val save = canvas.saveLayer(null, null) + canvas.translate(-w.x * d, -w.y * d) + model.drawRunsIn(canvas, w.x, w.y, w.w, w.h, bright.toArgb(), d, state.fonts, palette) + canvas.translate(w.x * d, w.y * d) + val center = (phase * 1.6f - 0.3f) * width + val band = width * 0.18f + val mask = android.graphics.Paint().apply { + xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.DST_IN) + shader = android.graphics.LinearGradient( + center - band, 0f, center + band, 0f, + intArrayOf(0, 0xFF000000.toInt(), 0), floatArrayOf(0f, 0.5f, 1f), + android.graphics.Shader.TileMode.CLAMP, + ) + } + canvas.drawRect(0f, 0f, width, size.height, mask) + canvas.restoreToCount(save) + } + } +} + diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/Components.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/Components.kt new file mode 100644 index 0000000000..21040e67d5 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/Components.kt @@ -0,0 +1,172 @@ +package sh.zeron.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import sh.zeron.android.design.LocalDarkTheme +import sh.zeron.android.design.ProjectColors +import uniffi.zeron_core.ChatIndicator +import uniffi.zeron_core.SessionRow +import uniffi.zeron_core.projectColorIndex + +/** + * Grouped-list corners (Android 16 settings style): the group's outer + * corners are large, the seams between items small. + */ +fun segmentShape(index: Int, count: Int, outer: Dp = 24.dp, inner: Dp = 6.dp): Shape { + val top = if (index == 0) outer else inner + val bottom = if (index == count - 1) outer else inner + return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) +} + +/** A section title above a group. */ +@Composable +fun SectionHeader(text: String, modifier: Modifier = Modifier, trailing: @Composable (() -> Unit)? = null) { + Box(modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 20.dp, bottom = 8.dp)) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.CenterStart), + ) + trailing?.let { Box(Modifier.align(Alignment.CenterEnd)) { it() } } + } +} + +/** + * A group of items with segmented corners and 2dp seams. + */ +@Composable +fun SegmentedGroup( + count: Int, + modifier: Modifier = Modifier, + item: @Composable (index: Int, shape: Shape) -> Unit, +) { + Column(modifier.padding(horizontal = 12.dp)) { + for (i in 0 until count) { + item(i, segmentShape(i, count)) + if (i < count - 1) Spacer(Modifier.height(2.dp)) + } + } +} + +/** Project monogram tile, toned by the core's stable project color. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ProjectTile(name: String?, colorIndex: Int, size: Dp = 40.dp, modifier: Modifier = Modifier) { + val tone = ProjectColors.color(colorIndex, LocalDarkTheme.current) + // Expressive shapes: projects are "cookies", projectless sessions a circle. + val shape = when { + name == null -> CircleShape + size < 28.dp -> RoundedCornerShape(size * 0.28f) + else -> MaterialShapes.Cookie9Sided.toShape() + } + Box( + modifier.size(size).clip(shape).background(tone.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + if (name != null) { + val fontSize = (size.value * if (size < 28.dp) 0.62f else 0.42f).sp + Text( + name.trim().take(1).uppercase(), + color = tone, + fontWeight = FontWeight.SemiBold, + fontSize = fontSize, + style = androidx.compose.ui.text.TextStyle( + lineHeight = fontSize, + lineHeightStyle = androidx.compose.ui.text.style.LineHeightStyle( + androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center, + androidx.compose.ui.text.style.LineHeightStyle.Trim.Both, + ), + ), + ) + } else { + sh.zeron.android.design.ZIcon(sh.zeron.android.design.ZIcons.Home, null, Modifier.size(size * 0.5f), tint = tone) + } + } +} + +fun SessionRow.colorIndex(): Int = (project?.colorIndex ?: projectColorIndex("home")).toInt() + +@Composable +fun successColor(): Color = if (LocalDarkTheme.current) Color(0xFF34D399) else Color(0xFF15803D) + +/** Live status at the trailing edge of a session row (desktop wording). */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun StatusLabel(row: SessionRow) { + @Composable + fun label(text: String, color: Color, dot: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (dot) { + Box(Modifier.size(7.dp).clip(CircleShape).background(color)) + } else { + sh.zeron.android.design.ZIcon(sh.zeron.android.design.ZIcons.Check, null, Modifier.size(15.dp), tint = color) + } + Spacer(Modifier.width(5.dp)) + Text(text, style = MaterialTheme.typography.labelLarge, color = color) + } + } + when (row.indicator) { + ChatIndicator.WORKING -> LoadingIndicator(Modifier.size(26.dp)) + ChatIndicator.AWAITING_INPUT -> label("Input", MaterialTheme.colorScheme.primary, dot = true) + ChatIndicator.ERRORED -> label("Failed", MaterialTheme.colorScheme.error, dot = true) + ChatIndicator.COMPLETED -> label("Done", successColor(), dot = false) + ChatIndicator.IDLE -> Row(verticalAlignment = Alignment.CenterVertically) { + if (row.unseen) { + Box(Modifier.size(7.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) + Spacer(Modifier.width(5.dp)) + } + Text(row.timeLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) + } + } +} + +/** The composer's surface: bright in light (like a sheet of paper), raised in dark. */ +@Composable +fun composerContainer(): Color = + if (LocalDarkTheme.current) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainerLowest + +@Composable +fun chipContainer(): Color = + if (LocalDarkTheme.current) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surfaceContainer + +/** Segmented list shapes, with a lone item rounded like a whole group. */ +@Composable +fun segmentedShapes(index: Int, count: Int): androidx.compose.material3.ListItemShapes = + if (count == 1) { + androidx.compose.material3.ListItemDefaults.shapes(shape = RoundedCornerShape(24.dp)) + } else { + androidx.compose.material3.ListItemDefaults.segmentedShapes(index, count) + } diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/Composer.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/Composer.kt new file mode 100644 index 0000000000..ddf8daeb0b --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/Composer.kt @@ -0,0 +1,471 @@ +package sh.zeron.android.ui + +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.DataUsage +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Send +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material.icons.outlined.Schedule +import sh.zeron.android.design.HarnessMark +import sh.zeron.android.transcript.TranscriptState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.focus.FocusRequester +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import uniffi.zeron_core.QueueEditAction +import uniffi.zeron_core.QueueEditLease +import uniffi.zeron_core.QueueEditStart +import java.util.UUID +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import sh.zeron.android.core.AppModel +import uniffi.zeron_core.BusyPolicy +import uniffi.zeron_core.ChatConfig +import uniffi.zeron_core.ComposerState +import uniffi.zeron_core.CoreClient +import uniffi.zeron_core.InputRequest +import uniffi.zeron_core.ModelInfo +import uniffi.zeron_core.QueueGate +import uniffi.zeron_core.QueueItem +import uniffi.zeron_core.SandboxLevel +import uniffi.zeron_core.SendRequest +import uniffi.zeron_core.SessionHandle +import uniffi.zeron_core.SessionRow +import uniffi.zeron_core.UserInputAnswer +import uniffi.zeron_core.fallbackModels +import uniffi.zeron_core.reasoningLabel + +private enum class Delivery(val label: String) { Queue("Queue"), Steer("Steer"), Interrupt("Stop & send") } + +/** + * The session composer: the shared surface plus this chat's context chips, + * staged photos, `@` mentions, and editing of messages queued behind the + * live turn (a host lease, renewed while the edit is open). + */ +@Composable +fun Composer( + model: AppModel, + client: CoreClient, + handle: SessionHandle, + c: ComposerState, + row: SessionRow?, + transcript: TranscriptState, +) { + val draft = remember(c.chatId) { ComposerModel() } + val scope = rememberCoroutineScope() + val focus = remember { FocusRequester() } + var error by remember { mutableStateOf(null) } + var delivery by remember { mutableStateOf(Delivery.Queue) } + val running = c.live.turnRunning + val canSteer = c.host.capabilities.midTurnSteering == true + + // Queue editing: the row being edited, its lease, and the user's own draft parked meanwhile. + var editingId by remember { mutableStateOf(null) } + var lease by remember { mutableStateOf(null) } + var editBase by remember { mutableStateOf?>(null) } + var renewal by remember { mutableStateOf(null) } + var stash by remember { mutableStateOf>?>(null) } + var editPending by remember { mutableStateOf(false) } + + fun restoreStash() { + stash?.let { (text, images) -> + draft.clear() + draft.setText(text) + draft.images.addAll(images) + } + stash = null + } + + fun finishEdit(text: String?) { + val held = lease ?: return + val base = editBase + renewal?.cancel() + lease = null + editBase = null + editingId = null + // Unchanged: release the row as it was (a commit would drop what the composer never showed). + val body = if (text != null && base != null) { + if (text == base.second) null else replacingVisible(base.first, base.second, text) + } else { + text + } + scope.launch { runCatching { handle.finishQueuedEdit(held, if (body == null) QueueEditAction.CANCEL else QueueEditAction.COMMIT, body) } } + } + + fun beginEdit(id: String) { + if (editPending) return + editPending = true + scope.launch { + try { + if (editingId != null) { + finishEdit(null) + restoreStash() + } + val start = runCatching { handle.beginQueuedEdit(id, UUID.randomUUID().toString()) }.getOrNull() + if (start !is QueueEditStart.Acquired) { + error = "Can't edit right now — another device is editing it, or it was just sent." + return@launch + } + lease = start.lease + renewal = scope.launch { + while (true) { + delay(20_000) + val held = lease ?: break + if (!runCatching { handle.renewQueuedEdit(held) }.getOrDefault(false)) break + } + } + val item = handle.composer().queue.firstOrNull { it.id == id } + editBase = item?.let { it.text to it.visibleText } + if (stash == null) stash = draft.text to draft.images.toList() + draft.clear() + draft.setText(item?.visibleText ?: "") + editingId = id + error = null + focus.requestFocus() + } finally { + editPending = false + } + } + } + + fun send() { + if (!draft.hasContent) return + if (editingId != null) { + finishEdit(draft.encoded()) + restoreStash() + return + } + val body = draft.encoded() + try { + val queued = running && delivery == Delivery.Queue + if (delivery == Delivery.Interrupt && running) handle.interrupt() + handle.send( + SendRequest( + body, + draft.images.map { it.outgoing }, + null, + if (running && delivery == Delivery.Steer) BusyPolicy.STEER else BusyPolicy.QUEUE, + ), + ) + draft.clear() + error = null + delivery = Delivery.Queue + // An immediate send gets the runway; one queued behind a live turn + // takes it over once its bubble lands. + if (queued) transcript.expectQueuedTurn() else transcript.beginOwnTurn() + } catch (e: Exception) { + error = "Couldn't send: ${e.message}" + } + } + + // Leaving the session gives an edited row straight back. + DisposableEffect(c.chatId) { onDispose { finishEdit(null) } } + + Column(Modifier.padding(horizontal = 12.dp).padding(bottom = 8.dp)) { + if (c.queue.isNotEmpty()) QueuePanel(c.queue, handle, editingId, onEdit = ::beginEdit) + MentionSuggestions(draft, search = { q -> + client.searchFiles(c.host.deviceId, c.chatId, null, q) + }) + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 16.dp, bottom = 6.dp)) + } + if (editingId != null) { + StatusBanner("Editing queued message", "Cancel" to { + finishEdit(null) + restoreStash() + }) + } + ComposerSurface( + model = draft, + placeholder = if (editingId != null) "Edit queued message" else "Message ${row?.harnessLabel ?: "the agent"}", + action = when { + editingId != null -> ComposerAction.Send + running && !draft.hasContent -> ComposerAction.Stop + running -> ComposerAction.Queue + else -> ComposerAction.Send + }, + onAction = { if (editingId == null && running && !draft.hasContent) runCatching { handle.interrupt() } else send() }, + attach = editingId == null, + focusRequester = focus, + ) { + if (running && editingId == null) DeliveryChip(delivery, canSteer) { delivery = it } + SessionChips(client, c, row) + } + } +} + +/** The row's raw text with its visible part replaced, keeping the hidden context after it. */ +private fun replacingVisible(raw: String, visible: String, edited: String): String { + if (edited.isBlank() || raw == visible) return edited + val body = raw.trimStart() + if (body.startsWith(visible)) return edited + body.substring(visible.length) + // Attachment-only rows show a placeholder: all of the raw text is context. + return if (body.isEmpty()) edited else edited + "\n\n" + body +} + +@Composable +private fun DeliveryChip(current: Delivery, canSteer: Boolean, onChange: (Delivery) -> Unit) { + var open by remember { mutableStateOf(false) } + ContextChip( + current.label, + leading = { ZIcon(ZIcons.Queue, null, Modifier.size(16.dp)) }, + onClick = { open = true }, + tint = MaterialTheme.colorScheme.primary, + ) { + ChoiceMenu( + open, + { open = false }, + listOf( + MenuSection( + "While the agent works", + listOfNotNull( + MenuChoice("Queue", current == Delivery.Queue, "Send when this turn ends") { onChange(Delivery.Queue) }, + if (canSteer) MenuChoice("Steer", current == Delivery.Steer, "Add to the running turn") { onChange(Delivery.Steer) } else null, + MenuChoice("Stop & send", current == Delivery.Interrupt, "Stop the turn, then send") { onChange(Delivery.Interrupt) }, + ), + ), + ), + ) + } +} + +@Composable +private fun SessionChips(client: CoreClient, c: ComposerState, row: SessionRow?) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val harness = row?.harness ?: "claude-code" + var models by remember { mutableStateOf?>(null) } + var menu by remember { mutableStateOf(null) } + + fun open(which: String) { + menu = which + if (models == null) scope.launch { + models = runCatching { client.listModels(c.host.deviceId, harness) }.getOrNull() ?: fallbackModels(harness) + } + } + + fun setConfig(change: (ChatConfig) -> ChatConfig) { + val current = client.sessionConfig(c.chatId) ?: ChatConfig(harness, row?.model, row?.reasoning, emptyMap(), SandboxLevel.WORKSPACE_WRITE) + runCatching { client.setSessionConfig(c.chatId, change(current)) } + } + + val modelLabel = row?.modelLabel ?: row?.harnessLabel + if (modelLabel != null) { + ContextChip(modelLabel, leading = { HarnessMark(harness, 14.dp) }, onClick = { open("model") }) { + ChoiceMenu(menu == "model", { menu = null }, listOf(MenuSection(row?.harnessLabel, models.orEmpty().map { m -> + MenuChoice(m.label, m.id == row?.model, m.description) { setConfig { it.copy(model = m.id) } } + }))) + } + } + row?.reasoning?.takeIf { it.isNotEmpty() }?.let { level -> + ContextChip(reasoningLabel(level), leading = { ZIcon(ZIcons.Effort, null, Modifier.size(16.dp)) }, onClick = { open("effort") }) { + val levels = models?.let { list -> (list.firstOrNull { it.id == row.model } ?: list.firstOrNull())?.reasoningLevels }.orEmpty() + ChoiceMenu(menu == "effort", { menu = null }, listOf(MenuSection("Reasoning effort", levels.map { l -> + MenuChoice(reasoningLabel(l), l == level) { setConfig { it.copy(reasoning = l) } } + }))) + } + } + val pr = row?.pullRequest + if (pr != null) { + ContextChip("#${pr.number}", leading = { ZIcon(ZIcons.PullRequest, null, Modifier.size(16.dp)) }, onClick = { + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(pr.url)) + }) + } else { + row?.branch?.takeIf { it.isNotEmpty() }?.let { + ContextChip(it, leading = { ZIcon(ZIcons.Branch, null, Modifier.size(16.dp)) }, onClick = {}) + } + } + val tokens = c.contextUsage?.tokens + val window = c.contextUsage?.window + if (tokens != null && window != null && window > 0u) { + val fraction = tokens.toDouble() / window.toDouble() + if (fraction >= 0.5) { + ContextChip( + "${(fraction * 100).toInt()}% context", + leading = { ZIcon(if (fraction >= 0.85) ZIcons.Warning else ZIcons.Context, null, Modifier.size(16.dp)) }, + onClick = {}, + tint = if (fraction >= 0.85) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun StatusBanner(text: String, action: Pair Unit>?) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Row(Modifier.padding(start = 16.dp, end = if (action != null) 4.dp else 16.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(vertical = 10.dp).weight(1f, fill = false)) + action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } } + } + } +} + +/** The agent asked something: answer with its options instead of typing. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) +@Composable +fun QuestionPanel(input: InputRequest, onSubmit: (List) -> Unit) { + val picked = remember(input.requestId) { input.questions.associate { it.id to mutableStateListOf() } } + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + for (q in input.questions) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (q.header.isNotEmpty()) Text(q.header, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(q.question, style = MaterialTheme.typography.bodyLarge) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + val selection = picked.getValue(q.id) + for (option in q.options) { + ToggleButton( + checked = option in selection, + onCheckedChange = { on -> + if (!q.multiSelect) selection.clear() + if (on) selection.add(option) else selection.remove(option) + }, + ) { Text(option) } + } + } + } + } + val ready = input.questions.all { picked.getValue(it.id).isNotEmpty() } + Button( + onClick = { onSubmit(input.questions.map { UserInputAnswer(it.id, picked.getValue(it.id).toList()) }) }, + enabled = ready, + shapes = ButtonDefaults.shapes(), + modifier = Modifier.align(Alignment.End), + ) { + ZIcon(ZIcons.Send, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Answer") + } + } + } +} + +/** Messages queued behind the live turn (shared across devices). */ +@Composable +fun QueuePanel(queue: List, handle: SessionHandle, editingId: String?, onEdit: (String) -> Unit) { + val scope = rememberCoroutineScope() + Column(Modifier.fillMaxWidth().padding(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + queue.forEachIndexed { i, item -> + val editing = item.id == editingId + Surface( + shape = segmentShape(i, queue.size, outer = 20.dp, inner = 6.dp), + color = if (editing) MaterialTheme.colorScheme.secondaryContainer else composerContainer(), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically) { + ZIcon(ZIcons.Queue, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + item.visibleText.ifEmpty { "Attachment" }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + (if (editing) "Editing" else gate(item))?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menu = true }) { ZIcon(ZIcons.More, "Queued message actions", Modifier.size(20.dp)) } + ActionMenu( + menu, + { menu = false }, + listOfNotNull( + MenuAction("Send now", ZIcons.Send) { scope.launch { runCatching { handle.deliverQueuedNow(item.id) } } }, + if (!editing && item.gate == null) MenuAction("Edit", ZIcons.Rename) { onEdit(item.id) } else null, + if (i > 0) MenuAction("Move up", ZIcons.ChevronUp) { runCatching { handle.moveQueuedBy(item.id, -1) } } else null, + if (i < queue.size - 1) MenuAction("Move down", ZIcons.ChevronDown) { runCatching { handle.moveQueuedBy(item.id, 1) } } else null, + MenuAction("Remove", ZIcons.Delete, destructive = true) { scope.launch { runCatching { handle.removeQueued(item.id) } } }, + ), + ) + } + } + } + } + } +} + +private fun gate(item: QueueItem): String? = when (val g = item.gate) { + is QueueGate.Editing -> if (g.mine) "Editing" else "Being edited" + is QueueGate.ReviewRequired -> "Needs review" + null -> if (item.actionPending) "Updating" else null +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerModel.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerModel.kt new file mode 100644 index 0000000000..7ee7c863b2 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerModel.kt @@ -0,0 +1,128 @@ +package sh.zeron.android.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import uniffi.zeron_core.FileMatch +import uniffi.zeron_core.OutgoingAttachment +import uniffi.zeron_core.fileMentionLink +import java.io.ByteArrayOutputStream +import java.util.UUID +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** An image staged in the composer before sending (JPEG, long side ≤ 2560px). */ +class StagedImage(val id: String, val name: String, val bytes: ByteArray, val thumb: ImageBitmap) { + val outgoing get() = OutgoingAttachment(name, "image/jpeg", bytes) +} + +/** + * What's being composed: the prompt (with its cursor, for `@` mentions), + * staged images, and the mention tokens it shows. + */ +@Stable +class ComposerModel(initial: String = "") { + var value by mutableStateOf(TextFieldValue(initial, TextRange(initial.length))) + val images = mutableStateListOf() + /** `@name` shown in the composer → the file it stands for. */ + private val tokens = LinkedHashMap() + + val text: String get() = value.text + val hasContent: Boolean get() = value.text.isNotBlank() || images.isNotEmpty() + + fun setText(s: String) { + value = TextFieldValue(s, TextRange(s.length)) + } + + fun clear() { + setText("") + images.clear() + tokens.clear() + } + + /** The `@query` being typed at the cursor: its start offset and query. */ + fun activeQuery(): Pair? { + val t = value.text + if (!value.selection.collapsed) return null + val cursor = value.selection.start + var i = cursor - 1 + while (i >= 0) { + val c = t[i] + if (c == '@') { + val before = if (i > 0) t[i - 1] else ' ' + return if (before == ' ' || before == '\n') i to t.substring(i + 1, cursor) else null + } + if (c == ' ' || c == '\n') return null + i-- + } + return null + } + + /** Replace the `@query` with a token for `file` (parent folder on basename clashes). */ + fun insertMention(file: FileMatch) { + val (start, _) = activeQuery() ?: return + val parts = file.path.trim('/').split('/') + var token = "@" + (parts.lastOrNull() ?: file.path) + val existing = tokens[token] + if (existing != null && existing.path != file.path && parts.size > 1) token = "@" + parts.takeLast(2).joinToString("/") + tokens[token] = file + val t = value.text + val cursor = value.selection.start + val next = t.substring(0, start) + token + " " + t.substring(cursor) + value = TextFieldValue(next, TextRange(start + token.length + 1)) + } + + /** The text to send: live tokens become canonical `zeron-file:` links. */ + fun encoded(): String { + var out = value.text + for ((token, file) in tokens.entries.sortedByDescending { it.key.length }) { + if (out.contains(token)) out = out.replace(token, fileMentionLink(file.path, file.isDir)) + } + return out.trim() + } +} + +object Staging { + const val MAX_IMAGES = 8 + private const val MAX_SIDE = 2560 + private const val MAX_BYTES = 24 * 1024 * 1024 + + /** Decode, downscale and JPEG-encode a picked image (off the main thread). */ + suspend fun stage(context: Context, uri: Uri): StagedImage? = withContext(Dispatchers.IO) { + runCatching { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val w = info.size.width + val h = info.size.height + val scale = min(1f, MAX_SIDE.toFloat() / max(w, h)) + if (scale < 1f) decoder.setTargetSize((w * scale).roundToInt(), (h * scale).roundToInt()) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + stage(bitmap) + }.getOrNull() + } + + fun stage(bitmap: Bitmap): StagedImage? { + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 86, out) + val bytes = out.toByteArray() + if (bytes.size > MAX_BYTES) return null + val t = 180f / max(bitmap.width, bitmap.height) + val thumb = if (t < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * t).roundToInt(), (bitmap.height * t).roundToInt(), true) else bitmap + val id = UUID.randomUUID().toString() + return StagedImage(id, "image-${id.take(8)}.jpg", bytes, thumb.asImageBitmap()) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerParts.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerParts.kt new file mode 100644 index 0000000000..cb9c1425ef --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerParts.kt @@ -0,0 +1,306 @@ +package sh.zeron.android.ui + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import sh.zeron.android.design.GeistMono +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import uniffi.zeron_core.FileMatch + +/** + * The composer, shared by sessions and the new-session page. One tonal + * surface with two states: a resting one-line capsule — [+] Message… [↑] — + * and, while focused or holding a draft, a card with staged photos and the + * full-width prompt above a toolbar of context chips. + */ +@Composable +fun ComposerSurface( + model: ComposerModel, + placeholder: String, + action: ComposerAction, + onAction: () -> Unit, + modifier: Modifier = Modifier, + alwaysCard: Boolean = false, + attach: Boolean = true, + focusRequester: FocusRequester = remember { FocusRequester() }, + chips: @Composable RowScope.() -> Unit = {}, +) { + var focused by remember { mutableStateOf(false) } + val card = alwaysCard || focused || model.hasContent + Surface( + shape = RoundedCornerShape(28.dp), + color = composerContainer(), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec())) { + AnimatedVisibility(model.images.isNotEmpty() && card) { StagedThumbnails(model) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 56.dp)) { + if (!card) { + Spacer(Modifier.width(4.dp)) + AttachButton(model, attach) + } else { + Spacer(Modifier.width(20.dp)) + } + Box(Modifier.weight(1f).padding(vertical = if (card) 16.dp else 8.dp)) { + if (model.text.isEmpty()) { + Text(placeholder, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + BasicTextField( + model.value, + { model.value = it }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines = if (card) 8 else 2, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused }, + ) + } + if (!card) { + Spacer(Modifier.width(8.dp)) + ActionButton(action, model.hasContent, onAction) + Spacer(Modifier.width(8.dp)) + } else { + Spacer(Modifier.width(16.dp)) + } + } + AnimatedVisibility(card, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 8.dp, bottom = 8.dp), + ) { + AttachButton(model, attach) + Row( + Modifier + .weight(1f) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + // Chips scrolling under the send button fade out. + val fade = 24.dp.toPx() + drawRect( + Brush.horizontalGradient(listOf(Color.Black, Color.Transparent), startX = size.width - fade, endX = size.width), + topLeft = Offset(size.width - fade, 0f), + blendMode = BlendMode.DstIn, + ) + } + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + content = chips, + ) + Spacer(Modifier.width(8.dp)) + ActionButton(action, model.hasContent, onAction) + } + } + } + } +} + +/** Staged photos above the prompt, each removable. */ +@Composable +private fun StagedThumbnails(model: ComposerModel) { + Row( + Modifier.horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (image in model.images) { + Box(Modifier.size(72.dp)) { + Image(image.thumb, "Attached image", Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp)), contentScale = ContentScale.Crop) + Surface( + onClick = { model.images.remove(image) }, + shape = CircleShape, + color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f), + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(22.dp), + ) { + Box(contentAlignment = Alignment.Center) { ZIcon(ZIcons.Close, "Remove image", Modifier.size(14.dp)) } + } + } + } + } +} + +/** [+] → Photos / Files / Paste image, staged into the composer. */ +@Composable +private fun AttachButton(model: ComposerModel, enabled: Boolean) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var open by remember { mutableStateOf(false) } + fun add(uris: List) { + scope.launch { + for (uri in uris.take(Staging.MAX_IMAGES - model.images.size)) Staging.stage(context, uri)?.let { model.images.add(it) } + } + } + val photos = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(Staging.MAX_IMAGES)) { add(it) } + val files = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { add(it) } + Box { + IconButton(onClick = { open = true }, enabled = enabled && model.images.size < Staging.MAX_IMAGES) { + ZIcon(ZIcons.Plus, "Attach", Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + val clip = if (open) context.getSystemService(android.content.ClipboardManager::class.java)?.primaryClip else null + val pasted = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.uri + ?.takeIf { context.contentResolver.getType(it)?.startsWith("image/") == true } + ActionMenu( + open, + { open = false }, + listOfNotNull( + MenuAction("Photos", ZIcons.Image) { photos.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + MenuAction("Files", ZIcons.Folder) { files.launch(arrayOf("image/*")) }, + pasted?.let { uri -> MenuAction("Paste image", ZIcons.Copy) { add(listOf(uri)) } }, + ), + ) + } +} + +/** Files matching the `@query` being typed, shown above the composer. */ +@Composable +fun MentionSuggestions(model: ComposerModel, search: suspend (String) -> List, modifier: Modifier = Modifier) { + val query = model.activeQuery()?.second + var files by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(query) { + if (query == null) { + files = emptyList() + return@LaunchedEffect + } + delay(120) + files = runCatching { search(query) }.getOrDefault(emptyList()).take(5) + } + AnimatedVisibility(query != null && files.isNotEmpty(), enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), modifier = modifier) { + Surface( + shape = RoundedCornerShape(24.dp), + color = composerContainer(), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + Column(Modifier.padding(6.dp)) { + files.forEach { file -> + val name = file.path.trimEnd('/').substringAfterLast('/') + val dir = file.path.trimEnd('/').substringBeforeLast('/', "") + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable { model.insertMention(file) } + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ZIcon(if (file.isDir) ZIcons.Folder else ZIcons.Text, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Column { + Text(name, style = MaterialTheme.typography.bodyLargeEmphasized, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (dir.isNotEmpty()) { + Text(dir, style = MaterialTheme.typography.bodySmall, fontFamily = GeistMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } + } +} + +/** An expressive choice menu: one group, inline section labels, a trailing check on the choice. */ +@Composable +fun ChoiceMenu(expanded: Boolean, onDismiss: () -> Unit, sections: List) { + DropdownMenuPopup(expanded = expanded, onDismissRequest = onDismiss) { + val groups = sections.filter { it.choices.isNotEmpty() } + val all = groups.sumOf { it.choices.size } + DropdownMenuGroup(shapes = MenuDefaults.groupShape(0, 1)) { + var index = 0 + groups.forEach { section -> + section.title?.let { title -> MenuDefaults.Label { Text(title, style = MaterialTheme.typography.labelMedium) } } + section.choices.forEach { choice -> + DropdownMenuItem( + selected = choice.selected, + onClick = { + choice.onClick() + onDismiss() + }, + text = { Text(choice.label) }, + supportingText = choice.supporting?.let { { Text(it) } }, + shapes = MenuDefaults.itemShape(index++, all), + leadingIcon = choice.leading, + selectedLeadingIcon = choice.leading, + trailingIcon = if (choice.selected) { + { ZIcon(ZIcons.Check, null, Modifier.size(20.dp)) } + } else { + null + }, + colors = MenuDefaults.selectableItemColors( + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTrailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) + } + } + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerSurface.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerSurface.kt new file mode 100644 index 0000000000..1b2bcbf54b --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/ComposerSurface.kt @@ -0,0 +1,181 @@ +package sh.zeron.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** What the composer's trailing button does right now. */ +enum class ComposerAction { Send, Queue, Stop } + +/** Send / queue / stop: one control whose shape morphs on press. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ActionButton(action: ComposerAction, hasText: Boolean, onClick: () -> Unit) { + val haptics = LocalHapticFeedback.current + AnimatedContent( + action == ComposerAction.Stop && !hasText, + transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, + label = "action", + ) { stop -> + if (stop) { + FilledTonalIconButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onClick() + }, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.size(40.dp), + ) { sh.zeron.android.design.ZIcon(sh.zeron.android.design.ZIcons.Stop, "Stop", Modifier.size(18.dp)) } + } else { + FilledIconButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onClick() + }, + enabled = hasText, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledIconButtonColors( + // White on the accent in both appearances, like iOS (the + // dark scheme's on-primary is a navy that muddies the arrow). + contentColor = Color.White, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ), + modifier = Modifier.size(40.dp), + ) { sh.zeron.android.design.ZIcon(sh.zeron.android.design.ZIcons.Send, if (action == ComposerAction.Queue) "Queue" else "Send", Modifier.size(22.dp)) } + } + } +} + +/** A context chip in the composer toolbar; `menu` is its (lazily built) choice menu. */ +@Composable +fun ContextChip( + label: String, + leading: @Composable () -> Unit, + onClick: () -> Unit, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + menu: @Composable () -> Unit = {}, +) { + Box { + Surface( + onClick = onClick, + shape = RoundedCornerShape(50), + color = chipContainer(), + contentColor = tint, + ) { + Row( + Modifier.heightIn(min = 34.dp).padding(start = 10.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { leading() } + Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + menu() + } +} + +class MenuChoice( + val label: String, + val selected: Boolean = false, + val supporting: String? = null, + val leading: (@Composable () -> Unit)? = null, + val onClick: () -> Unit, +) + +class MenuSection(val title: String?, val choices: List) + +class MenuAction(val label: String, @androidx.annotation.DrawableRes val icon: Int, val destructive: Boolean = false, val onClick: () -> Unit) + +/** An expressive action menu (one segmented group). */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ActionMenu(expanded: Boolean, onDismiss: () -> Unit, actions: List) { + DropdownMenuPopup(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuGroup(shapes = MenuDefaults.groupShape(0, 1)) { + actions.forEachIndexed { i, a -> + val tint = if (a.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + DropdownMenuItem( + onClick = { + onDismiss() + a.onClick() + }, + text = { Text(a.label, color = tint) }, + shape = MenuDefaults.itemShape(i, actions.size).shape, + leadingIcon = { sh.zeron.android.design.ZIcon(a.icon, null, Modifier.size(20.dp), tint = tint) }, + ) + } + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/Expressive.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/Expressive.kt new file mode 100644 index 0000000000..4c7c3322a1 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/Expressive.kt @@ -0,0 +1,202 @@ +package sh.zeron.android.ui + +import androidx.annotation.DrawableRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons + +/** A round tonal icon button whose shape morphs on press. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TonalCircleButton( + @DrawableRes icon: Int, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + container: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + content: Color = MaterialTheme.colorScheme.onSurface, +) { + FilledTonalIconButton( + onClick = onClick, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = container, contentColor = content), + modifier = modifier.size(size), + ) { ZIcon(icon, contentDescription, Modifier.size(22.dp)) } +} + +/** The big expressive page title with a status line and round actions. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenHeader(title: String, subtitle: String?, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {}) { + Row( + modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.displaySmallEmphasized, color = MaterialTheme.colorScheme.onSurface) + if (subtitle != null) { + Spacer(Modifier.height(2.dp)) + Text(subtitle, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions) + } +} + +/** A filter pill: filled when selected, tonal otherwise, with an optional count. */ +@Composable +fun Pill(label: String, selected: Boolean, onClick: () -> Unit, count: Int? = null, @DrawableRes icon: Int? = null) { + val container by animateColorAsState( + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.motionScheme.defaultEffectsSpec(), + label = "pill", + ) + val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + Surface(onClick = onClick, shape = RoundedCornerShape(50), color = container, contentColor = fg) { + Row( + Modifier.heightIn(min = 40.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + icon?.let { ZIcon(it, null, Modifier.size(18.dp)) } + Text(label, style = MaterialTheme.typography.labelLarge) + if (count != null && count > 0) { + Box( + Modifier + .clip(CircleShape) + .background(if (selected) fg.copy(alpha = 0.2f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) + .padding(horizontal = 7.dp, vertical = 1.dp), + ) { + Text("$count", style = MaterialTheme.typography.labelMedium, color = if (selected) fg else MaterialTheme.colorScheme.primary) + } + } + } + } +} + +class NavItem(val label: String, @DrawableRes val icon: Int, val selected: Boolean, val onClick: () -> Unit) + +/** Bottom navigation as a floating capsule, inset from the screen edges. */ +@Composable +fun FloatingNavBar(items: List, modifier: Modifier = Modifier, trailing: (@Composable () -> Unit)? = null) { + Row(modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(36.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 0.dp, + modifier = Modifier.weight(1f), + ) { + Row(Modifier.padding(6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (item in items) { + val bg by animateColorAsState( + if (item.selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + MaterialTheme.motionScheme.defaultEffectsSpec(), + label = "nav", + ) + Surface( + onClick = item.onClick, + shape = RoundedCornerShape(30.dp), + color = bg, + contentColor = if (item.selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) { + Column(Modifier.padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + ZIcon(item.icon, null, Modifier.size(24.dp)) + Spacer(Modifier.height(2.dp)) + Text(item.label, style = if (item.selected) MaterialTheme.typography.labelMediumEmphasized else MaterialTheme.typography.labelMedium) + } + } + } + } + } + trailing?.let { + Spacer(Modifier.width(10.dp)) + it() + } + } +} + +/** + * "New session" — the always-there primary action above the nav (the iOS + * accessory bar), with the live summary of what needs attention. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun NewSessionBar(summary: String?, onClick: () -> Unit, modifier: Modifier = Modifier) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { ZIcon(ZIcons.NewSession, null, Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onPrimary) } + Spacer(Modifier.width(14.dp)) + Text("New session", style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f)) + if (summary != null) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) + Spacer(Modifier.width(6.dp)) + Text(summary, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +val FloatingChromePadding = PaddingValues(bottom = 176.dp) + +/** The page tone behind the status bar once content scrolls under it. */ +@Composable +fun BoxScope.StatusBarScrim(scrolled: Boolean) { + val color by animateColorAsState( + if (scrolled) MaterialTheme.colorScheme.background else Color.Transparent, + label = "status-scrim", + ) + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .windowInsetsTopHeight(androidx.compose.foundation.layout.WindowInsets.statusBars) + .background(color), + ) +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/NewSessionScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/NewSessionScreen.kt new file mode 100644 index 0000000000..1bb7fb6a51 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/NewSessionScreen.kt @@ -0,0 +1,418 @@ +package sh.zeron.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Computer +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.background +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.HarnessMark +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import uniffi.zeron_core.ModelInfo +import uniffi.zeron_core.fallbackHarnesses +import uniffi.zeron_core.fallbackModels +import uniffi.zeron_core.modelLabel +import uniffi.zeron_core.harnessLabel +import uniffi.zeron_core.reasoningLabel + +private data class ModelChoice(val harness: String, val harnessLabel: String, val model: ModelInfo) + +/** The last catalog each host reported, so chips open on real model names. */ +private val modelCache = HashMap>() + +private fun catalogModels(): List = fallbackHarnesses().filter { it.offered }.flatMap { h -> + fallbackModels(h.id).map { ModelChoice(h.id, h.label, it) } +} + +/** + * "What are we building?" — a composer-first canvas. Context is set with the + * composer's chips (project, branch or host, model, effort); the prompt is + * focused at once so the common case is: tap, type, send. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewSessionScreen(model: AppModel, onClose: () -> Unit, onCreated: (String) -> Unit) { + val workspace by model.workspace.collectAsState() + val client by model.client.collectAsState() + var draft by remember { mutableStateOf(model.lastDraft) } + val composer = remember { ComposerModel() } + val focus = remember { FocusRequester() } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + val projects = workspace?.projects.orEmpty() + val hosts = workspace?.devices.orEmpty().filter { it.isExecutionHost } + val project = projects.firstOrNull { it.id == draft.projectId } + LaunchedEffect(workspace) { + if (draft.projectId == null && draft.hostId == null) { + draft = draft.copy(projectId = (projects.firstOrNull { it.deviceOnline } ?: projects.firstOrNull())?.id) + } + if (draft.projectId == null && draft.hostId == null) { + // No projects yet: run on the first reachable host. + draft = draft.copy(hostId = (hosts.firstOrNull { it.online } ?: hosts.firstOrNull())?.id) + } + } + val deviceId = project?.deviceId ?: draft.hostId ?: "" + + // Models for the draft's host: cached (or the built-in catalog) at once, then the host's own list. + var models by remember(deviceId) { mutableStateOf(modelCache[deviceId] ?: catalogModels()) } + LaunchedEffect(deviceId, client) { + val c = client ?: return@LaunchedEffect + if (deviceId.isEmpty()) return@LaunchedEffect + val harnesses = runCatching { c.listHarnesses(deviceId) }.getOrNull() ?: return@LaunchedEffect + val fresh = harnesses.filter { it.offered }.flatMap { h -> + (runCatching { c.listModels(deviceId, h.id) }.getOrNull() ?: fallbackModels(h.id)).map { ModelChoice(h.id, h.label, it) } + } + if (fresh.isNotEmpty()) { + modelCache[deviceId] = fresh + models = fresh + } + } + val choice = models.firstOrNull { it.harness == draft.harness && it.model.id == draft.model } + ?: models.firstOrNull { it.harness == draft.harness } + + LaunchedEffect(Unit) { focus.requestFocus() } + + fun create() { + model.lastDraft = draft + val id = model.createSession(draft.copy(model = draft.model ?: choice?.model?.id), composer.encoded(), composer.images.map { it.outgoing }) + if (id != null) onCreated(id) else scope.launch { snackbar.showSnackbar("Choose a project or a host that can run it.") } + } + + var composerBounds by remember { mutableStateOf(null) } + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + sh.zeron.android.design.WallpaperHero(model.wallpaper, cutout = { composerBounds }) + Scaffold( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { + TopAppBar( + title = { Text("New session", style = MaterialTheme.typography.titleLargeEmphasized, modifier = Modifier.padding(start = 12.dp)) }, + navigationIcon = { TonalCircleButton(ZIcons.Close, "Close", onClick = onClose, modifier = Modifier.padding(start = 8.dp), size = 44.dp) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = androidx.compose.ui.graphics.Color.Transparent), + ) + }, + ) { padding -> + Column( + Modifier + .padding(top = padding.calculateTopPadding()) + .fillMaxSize() + .imePadding() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // The headline lives in the free space above the composer. + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + HarnessMark(draft.harness, 40.dp, tint = MaterialTheme.colorScheme.onSurface) + Text( + "What are we building?", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + MentionSuggestions( + composer, + search = { q -> + val c = client + if (c == null || project == null) emptyList() else c.searchFiles(project.deviceId, null, project.id, q) + }, + modifier = Modifier.padding(horizontal = 12.dp).widthIn(max = 768.dp), + ) + ComposerSurface( + model = composer, + placeholder = "Describe the task", + action = ComposerAction.Send, + onAction = ::create, + alwaysCard = true, + focusRequester = focus, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 8.dp) + .widthIn(max = 768.dp) + .onGloballyPositioned { composerBounds = it.boundsInRoot() }, + ) { + ProjectChip(draft, projects, onPick = { draft = it }) + if (project != null) { + if (project.gitDetected) BranchChip(model, draft, project.deviceId, project.path) { draft = it } + } else { + HostChip(draft, hosts) { draft = it } + } + ModelChip(draft, choice, models) { draft = it } + val efforts = choice?.model?.reasoningLevels.orEmpty() + if (efforts.isNotEmpty()) { + val effort = draft.effort?.takeIf { it in efforts } ?: choice?.model?.defaultReasoning ?: efforts[efforts.size / 2] + EffortChip(effort, efforts) { draft = draft.copy(effort = it) } + } + } + } + } +} +} + +@Composable +private fun ProjectChip( + draft: sh.zeron.android.core.NewSessionDraft, + projects: List, + onPick: (sh.zeron.android.core.NewSessionDraft) -> Unit, +) { + var open by remember { mutableStateOf(false) } + val project = projects.firstOrNull { it.id == draft.projectId } + ContextChip( + project?.name ?: "No project", + leading = { + if (project != null) ProjectTile(project.name, project.colorIndex.toInt(), 18.dp) + else ZIcon(ZIcons.Home, null, Modifier.size(16.dp)) + }, + onClick = { open = true }, + ) + if (open) ProjectSheet(draft, projects, onDismiss = { open = false }) { + onPick(it) + open = false + } +} + +/** Projects grouped by the machine they live on; the choice keeps its tile and gains a check. */ +@Composable +private fun ProjectSheet( + draft: sh.zeron.android.core.NewSessionDraft, + projects: List, + onDismiss: () -> Unit, + onPick: (sh.zeron.android.core.NewSessionDraft) -> Unit, +) { + val sheet = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true) + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheet, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + androidx.compose.foundation.lazy.LazyColumn(contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) { + item { + Text( + "Project", + style = MaterialTheme.typography.headlineSmallEmphasized, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp), + ) + } + val byDevice = projects.groupBy { it.deviceId }.values.sortedByDescending { it.first().deviceOnline } + for (group in byDevice) { + val host = group.first() + item("h-${host.deviceId}") { + androidx.compose.foundation.layout.Row( + Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ZIcon(ZIcons.Laptop, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(8.dp)) + Text(host.deviceName ?: "Host", style = MaterialTheme.typography.titleSmallEmphasized, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(8.dp)) + Box( + Modifier.size(8.dp).clip(androidx.compose.foundation.shape.CircleShape).background( + if (host.deviceOnline) successColor() else MaterialTheme.colorScheme.outlineVariant, + ), + ) + if (!host.deviceOnline) { + Spacer(Modifier.size(6.dp)) + Text("Offline", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline) + } + } + } + group.forEachIndexed { i, p -> + item(p.id) { + ProjectRow( + selected = p.id == draft.projectId, + index = i, + count = group.size, + leading = { ProjectTile(p.name, p.colorIndex.toInt(), 40.dp) }, + title = p.name, + supporting = p.path.replace(Regex("^/(Users|home)/[^/]+"), "~"), + mono = true, + ) { onPick(draft.copy(projectId = p.id, hostId = null, branch = null)) } + } + } + } + item("none") { + Spacer(Modifier.size(16.dp)) + ProjectRow( + selected = draft.projectId == null, + index = 0, + count = 1, + leading = { IconTile(ZIcons.Home) }, + title = "No project", + supporting = "Run in a host's home folder", + mono = false, + ) { onPick(draft.copy(projectId = null, hostId = draft.hostId ?: projects.firstOrNull()?.deviceId, worktree = false)) } + } + } + } +} + +@Composable +private fun ProjectRow( + selected: Boolean, + index: Int, + count: Int, + leading: @Composable () -> Unit, + title: String, + supporting: String, + mono: Boolean, + onClick: () -> Unit, +) { + androidx.compose.material3.SegmentedListItem( + onClick = onClick, + shapes = segmentedShapes(index, count), + colors = androidx.compose.material3.ListItemDefaults.segmentedColors( + containerColor = if (selected) MaterialTheme.colorScheme.secondaryContainer else cardColor(), + ), + leadingContent = leading, + supportingContent = { + Text( + supporting, + style = MaterialTheme.typography.bodySmall, + fontFamily = if (mono) sh.zeron.android.design.GeistMono else null, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + }, + trailingContent = if (selected) { + { + Box( + Modifier.size(28.dp).clip(androidx.compose.foundation.shape.CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { ZIcon(ZIcons.Check, "Selected", Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onPrimary) } + } + } else { + null + }, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = androidx.compose.material3.ListItemDefaults.SegmentedGap), + ) { Text(title, style = MaterialTheme.typography.titleMedium) } +} + +@Composable +private fun HostChip( + draft: sh.zeron.android.core.NewSessionDraft, + hosts: List, + onPick: (sh.zeron.android.core.NewSessionDraft) -> Unit, +) { + var open by remember { mutableStateOf(false) } + ContextChip( + hosts.firstOrNull { it.id == draft.hostId }?.name ?: "Choose host", + leading = { ZIcon(ZIcons.Laptop, null, Modifier.size(16.dp)) }, + onClick = { open = true }, + ) { + ChoiceMenu(open, { open = false }, listOf(MenuSection("Run on", hosts.map { h -> + MenuChoice(h.name, h.id == draft.hostId, if (h.online) "Online" else "Offline") { onPick(draft.copy(hostId = h.id)) } + }))) + } +} + +@Composable +private fun BranchChip( + model: AppModel, + draft: sh.zeron.android.core.NewSessionDraft, + deviceId: String, + repoPath: String, + onPick: (sh.zeron.android.core.NewSessionDraft) -> Unit, +) { + var open by remember { mutableStateOf(false) } + var refs by remember(deviceId, repoPath) { mutableStateOf?>(null) } + val client by model.client.collectAsState() + LaunchedEffect(open, deviceId, repoPath) { + if (open && refs == null) { + refs = runCatching { client?.listRefs(deviceId, repoPath) }.getOrNull().orEmpty() + .sortedByDescending { it.current }.map { it.name } + } + } + ContextChip( + if (draft.worktree) "New worktree" else draft.branch ?: "Current branch", + leading = { ZIcon(ZIcons.Branch, null, Modifier.size(16.dp)) }, + onClick = { open = true }, + ) { + val branches = refs.orEmpty() + ChoiceMenu(open, { open = false }, listOf( + MenuSection("Checkout", listOf( + MenuChoice("New worktree", draft.worktree, "Run isolated from the checkout") { onPick(draft.copy(worktree = !draft.worktree)) }, + )), + MenuSection("Branch", branches.map { b -> + MenuChoice(b, b == (draft.branch ?: branches.firstOrNull())) { onPick(draft.copy(branch = b)) } + }), + )) + } +} + +@Composable +private fun ModelChip( + draft: sh.zeron.android.core.NewSessionDraft, + choice: ModelChoice?, + models: List, + onPick: (sh.zeron.android.core.NewSessionDraft) -> Unit, +) { + var open by remember { mutableStateOf(false) } + // Never the harness name in place of a model. + val title = choice?.model?.label ?: draft.model?.let { modelLabel(draft.harness, it) } + ?: fallbackModels(draft.harness).firstOrNull()?.label ?: harnessLabel(draft.harness) + ContextChip(title, leading = { HarnessMark(draft.harness, 14.dp) }, onClick = { open = true }) { + ChoiceMenu(open, { open = false }, models.groupBy { it.harness }.map { (harness, list) -> + MenuSection(list.first().harnessLabel, list.map { m -> + MenuChoice(m.model.label, m.harness == draft.harness && m.model.id == choice?.model?.id, leading = { HarnessMark(harness, 18.dp) }) { + onPick(draft.copy(harness = m.harness, model = m.model.id, effort = null)) + } + }) + }) + } +} + +@Composable +private fun EffortChip(effort: String, efforts: List, onPick: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + ContextChip(reasoningLabel(effort), leading = { ZIcon(ZIcons.Effort, null, Modifier.size(16.dp)) }, onClick = { open = true }) { + ChoiceMenu(open, { open = false }, listOf(MenuSection("Reasoning effort", efforts.map { e -> + MenuChoice(reasoningLabel(e), e == effort) { onPick(e) } + }))) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/SearchScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/SearchScreen.kt new file mode 100644 index 0000000000..ad66a5ce01 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/SearchScreen.kt @@ -0,0 +1,95 @@ +package sh.zeron.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons + +@Composable +fun SearchScreen(model: AppModel, onBack: () -> Unit, onOpen: (String) -> Unit) { + val client by model.client.collectAsState() + val workspace by model.workspace.collectAsState() + var query by remember { mutableStateOf("") } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + val results = remember(query, workspace) { + val q = query.trim() + if (q.isEmpty()) workspace?.front?.recent.orEmpty() else client?.search(q, 60u)?.map { it.session }.orEmpty() + } + LazyColumn( + Modifier.fillMaxSize().imePadding(), + contentPadding = PaddingValues(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item { + Row(Modifier.statusBarsPadding().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + TonalCircleButton(ZIcons.Back, "Back", onClick = onBack) + Spacer(Modifier.width(10.dp)) + // The search field: a full pill. + Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.weight(1f)) { + Row(Modifier.heightIn(min = 56.dp).padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + ZIcon(ZIcons.Search, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) Text("Search sessions", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge) + BasicTextField( + query, + { query = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + } + } + } + } + item { + Text( + if (query.isBlank()) "Recent" else "${results.size} results", + style = MaterialTheme.typography.titleSmallEmphasized, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 28.dp, top = 8.dp, bottom = 6.dp), + ) + } + itemsIndexed(results, key = { _, r -> r.id }) { i, row -> + Box(Modifier.padding(horizontal = 16.dp)) { + SessionItem(row, i, results.size, model, onOpen) { model.archive(it.id) } + } + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/SessionScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/SessionScreen.kt new file mode 100644 index 0000000000..f62ca5049a --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/SessionScreen.kt @@ -0,0 +1,239 @@ +package sh.zeron.android.ui + +import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.animation.animateColorAsState +import sh.zeron.android.design.ZIcon +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import sh.zeron.android.design.ZIcons +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.background +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.Geist +import sh.zeron.android.design.GeistMono +import sh.zeron.android.transcript.Transcript +import sh.zeron.android.transcript.TranscriptActions +import sh.zeron.android.transcript.TranscriptState +import uniffi.zeron_core.ComposerState +import uniffi.zeron_core.ConnectivityState +import uniffi.zeron_core.SendState +import uniffi.zeron_core.SessionHandle + +private data class TextSheet(val title: String, val text: String, val mono: Boolean) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SessionScreen(model: AppModel, chatId: String, onBack: () -> Unit) { + val client by model.client.collectAsState() + val core = client ?: return + val handle: SessionHandle = remember(chatId) { runCatching { core.openSession(chatId) }.getOrNull() } ?: run { + Text("This session isn't available.", Modifier.padding(32.dp)) + return + } + val transcript = remember(chatId) { TranscriptState() } + DisposableEffect(chatId) { + transcript.engine.attach(core, chatId) + handle.setViewAttached(true) + core.markSeen(chatId) + onDispose { + handle.setViewAttached(false) + transcript.close() + } + } + + // Composer-facing state: re-pulled on this chat's events. + var composer by remember { mutableStateOf(handle.composer()) } + val workspace by model.workspace.collectAsState() + val connectivity by model.connectivity.collectAsState() + LaunchedEffect(chatId) { + model.sessionEvents.collect { if (it == chatId) composer = handle.composer() } + } + // Upload rings on pending thumbnails follow the escort. + LaunchedEffect(composer.transferProgress) { transcript.uploadProgress = composer.transferProgress } + val row = remember(workspace, chatId) { model.row(chatId) } + + val context = LocalContext.current + var sheet by remember { mutableStateOf(null) } + val actions = remember(chatId) { + TranscriptActions( + openUrl = { url -> + val uri = Uri.parse(url) + if (uri.scheme == "http" || uri.scheme == "https") { + CustomTabsIntent.Builder().build().launchUrl(context, uri) + } else { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } + } + }, + loadImage = { ref -> + val device = handle.composer().host.deviceId + runCatching { + val bytes = core.readAttachment(device, ref) + withContext(Dispatchers.Default) { BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() } + }.getOrNull() + }, + showText = { title, text, mono -> sheet = TextSheet(title, text, mono) }, + ) + } + + val project = row?.project?.name ?: "No project" + val subtitle = composer.host.name?.let { "$project @ $it" } ?: project + var overflow by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + // Flat Material chrome: the header sits on the page and takes the + // container tone once the transcript scrolls under it; the transcript + // ends where the composer begins (nothing streams behind it). + val scrolled by remember { derivedStateOf { transcript.offset > 1f } } + val headerColor by animateColorAsState( + if (scrolled) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.background, + MaterialTheme.motionScheme.defaultEffectsSpec(), + label = "header", + ) + Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + Surface(color = headerColor) { + Row( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TonalCircleButton(ZIcons.Back, "Back", onClick = onBack, container = MaterialTheme.colorScheme.surfaceContainerHighest) + Column(Modifier.weight(1f).padding(horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(composer.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMediumEmphasized) + Text(subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Box { + TonalCircleButton(ZIcons.More, "More", onClick = { overflow = true }, container = MaterialTheme.colorScheme.surfaceContainerHighest) + ActionMenu( + overflow, + { overflow = false }, + listOfNotNull( + MenuAction("Copy transcript", ZIcons.Copy) { transcript.frame?.let { clipboard.setText(AnnotatedString(it.plainText())) } }, + row?.let { r -> MenuAction(if (r.pinned) "Unpin" else "Pin", ZIcons.Pin) { model.setPinned(chatId, !r.pinned) } }, + row?.let { MenuAction("Archive", ZIcons.Archive) { model.archive(chatId); onBack() } }, + ), + ) + } + } + } + Box(Modifier.weight(1f).fillMaxWidth()) { + Transcript(transcript, actions, Modifier.fillMaxSize()) + val showJump by remember { derivedStateOf { !transcript.following && transcript.distanceFromBottom > 400f } } + androidx.compose.animation.AnimatedVisibility( + showJump, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp), + ) { + SmallFloatingActionButton( + onClick = { transcript.scrollToBottom() }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { ZIcon(ZIcons.ArrowDown, "Jump to latest", Modifier.size(22.dp)) } + } + } + Column(Modifier.fillMaxWidth().imePadding().navigationBarsPadding()) { + Banner(composer, connectivity?.state, onRetry = { runCatching { handle.retryDelivery() } }) + composer.openInput?.let { QuestionPanel(it) { answers -> runCatching { handle.respondInput(it.requestId, answers) } } } + Composer(model, core, handle, composer, row, transcript) + } + } + + sheet?.let { s -> + ModalBottomSheet(onDismissRequest = { sheet = null }) { + Text(s.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp)) + SelectionContainer { + Text( + s.text, + fontFamily = if (s.mono) GeistMono else Geist, + style = if (s.mono) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) + } + } + } +} + +@Composable +private fun Banner(c: ComposerState, connectivity: ConnectivityState?, onRetry: () -> Unit) { + val text = when { + c.sendState == SendState.FAILED -> "Not delivered" + c.sendState == SendState.QUEUED -> "${c.host.name ?: "Host"} is offline — will send when it's back" + connectivity == ConnectivityState.OFFLINE -> "You're offline" + !c.room.connected && c.room.retryAtMs != null -> "Reconnecting…" + else -> null + } ?: return + StatusBanner(text, if (c.sendState == SendState.FAILED) "Retry" to onRetry else null) +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/SessionsScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/SessionsScreen.kt new file mode 100644 index 0000000000..4c2f34d1b3 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/SessionsScreen.kt @@ -0,0 +1,417 @@ +package sh.zeron.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.HarnessMark +import sh.zeron.android.design.LocalDarkTheme +import sh.zeron.android.design.ProjectColors +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import uniffi.zeron_core.ChatIndicator +import uniffi.zeron_core.ConnectivityState +import uniffi.zeron_core.PullRequestState +import uniffi.zeron_core.SessionRow +import uniffi.zeron_core.WorkspaceSnapshot + +private enum class Filter(val label: String) { All("All"), NeedsYou("Needs you"), Working("Working"), Pinned("Pinned") } + +/** "1 needs you" / "2 working" — the live summary beside New session. */ +fun liveSummary(ws: WorkspaceSnapshot): String? { + val (working, awaiting) = liveCounts(ws) + return when { + awaiting > 0 -> "$awaiting needs you" + working > 0 -> "$working working" + else -> null + } +} + +private fun frontRows(ws: WorkspaceSnapshot): List { + val seen = HashSet() + return (ws.front.pinned + ws.front.sections.flatMap { it.sessions } + ws.front.recent).filter { seen.add(it.id) } +} + +private fun liveCounts(ws: WorkspaceSnapshot): Pair { + val rows = frontRows(ws) + return rows.count { it.indicator == ChatIndicator.WORKING } to rows.count { it.indicator == ChatIndicator.AWAITING_INPUT } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SessionsScreen(model: AppModel, onOpen: (String) -> Unit) { + val workspace by model.workspace.collectAsState() + val connectivity by model.connectivity.collectAsState() + var filter by rememberSaveable { mutableStateOf(Filter.All) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + val pull = rememberPullToRefreshState() + val ws = workspace + val counts = remember(ws) { ws?.let { liveCounts(it) } ?: (0 to 0) } + + val subtitle = when { + connectivity?.state == ConnectivityState.OFFLINE -> "Offline" + connectivity?.state == ConnectivityState.RECONNECTING -> "Reconnecting…" + model.isDemo -> "Demo workspace" + else -> model.accountDetail.takeIf { it.isNotEmpty() } + } + + val archive: (SessionRow) -> Unit = { row -> + model.archive(row.id) + scope.launch { + if (snackbar.showSnackbar("Archived", actionLabel = "Undo", withDismissAction = true) == SnackbarResult.ActionPerformed) { + model.unarchive(row.id) + } + } + } + + val list = androidx.compose.foundation.lazy.rememberLazyListState() + val density = androidx.compose.ui.platform.LocalDensity.current + Box(Modifier.fillMaxSize()) { + sh.zeron.android.design.WallpaperHero( + model.wallpaper, + scrollFade = { + if (list.firstVisibleItemIndex > 0) 0f + else 1f - list.firstVisibleItemScrollOffset / (with(density) { 400.dp.toPx() }) + }, + ) + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + scope.launch { + model.refresh() + refreshing = false + } + }, + state = pull, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = pull, + isRefreshing = refreshing, + modifier = Modifier.align(Alignment.TopCenter).padding(WindowInsets.statusBars.asPaddingValues()), + ) + }, + ) { + LazyColumn( + state = list, + contentPadding = PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, bottom = 200.dp), + modifier = Modifier.fillMaxSize(), + ) { + item("header") { + ScreenHeader("Sessions", subtitle) { + if (connectivity?.state == ConnectivityState.OFFLINE) { + TonalCircleButton( + ZIcons.Offline, "Offline", onClick = { scope.launch { model.refresh() } }, + container = MaterialTheme.colorScheme.errorContainer, content = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + item("filters") { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (f in Filter.entries) { + Pill( + f.label, + filter == f, + onClick = { filter = f }, + count = when (f) { + Filter.NeedsYou -> counts.second + Filter.Working -> counts.first + else -> null + }, + ) + } + } + } + if (ws == null) return@LazyColumn + val front = ws.front + when (filter) { + Filter.All -> { + if (front.pinned.isNotEmpty()) group("pinned", "Pinned", front.pinned, model, onOpen, archive) + for (section in front.sections) { + group(section.id, section.name, section.sessions, model, onOpen, archive, collapsed = section.collapsed) { + model.setSectionCollapsed(section.id, !section.collapsed) + } + } + if (front.recent.isNotEmpty()) group("recent", "Recent", front.recent, model, onOpen, archive) + } + Filter.NeedsYou -> group("f", null, frontRows(ws).filter { it.indicator == ChatIndicator.AWAITING_INPUT }, model, onOpen, archive) + Filter.Working -> group("f", null, frontRows(ws).filter { it.indicator == ChatIndicator.WORKING }, model, onOpen, archive) + Filter.Pinned -> group("f", null, front.pinned, model, onOpen, archive) + } + val empty = when (filter) { + Filter.All -> front.pinned.isEmpty() && front.sections.isEmpty() && front.recent.isEmpty() + Filter.NeedsYou -> counts.second == 0 + Filter.Working -> counts.first == 0 + Filter.Pinned -> front.pinned.isEmpty() + } + if (empty) item("empty") { EmptyState(filter) } + } + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).padding(bottom = 180.dp)) + StatusBarScrim(scrolled = list.firstVisibleItemIndex > 0 || list.firstVisibleItemScrollOffset > 0) + } +} + +private fun LazyListScope.group( + id: String, + title: String?, + rows: List, + model: AppModel, + onOpen: (String) -> Unit, + archive: (SessionRow) -> Unit, + collapsed: Boolean = false, + onToggle: (() -> Unit)? = null, +) { + if (title != null) { + item("h-$id") { + Row( + Modifier.animateItem().fillMaxWidth().padding(start = 28.dp, end = 12.dp, top = 20.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleSmallEmphasized, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text("${rows.size}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + if (onToggle != null) { + IconButton(onClick = onToggle, modifier = Modifier.size(32.dp)) { + ZIcon(if (collapsed) ZIcons.ChevronDown else ZIcons.ChevronUp, if (collapsed) "Expand" else "Collapse", Modifier.size(18.dp)) + } + } + } + } + } else { + item("h-$id") { Spacer(Modifier.height(8.dp)) } + } + if (collapsed) return + itemsIndexed(rows, key = { _, r -> "$id/${r.id}" }) { i, row -> + Box(Modifier.animateItem().padding(horizontal = 16.dp).padding(bottom = ListItemDefaults.SegmentedGap)) { + SwipeableSessionRow(row, i, rows.size, model, onOpen, archive) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SwipeableSessionRow( + row: SessionRow, + index: Int, + count: Int, + model: AppModel, + onOpen: (String) -> Unit, + archive: (SessionRow) -> Unit, +) { + val state = rememberSwipeToDismissBoxState() + val shape = segmentedShapes(index, count).shape + SwipeToDismissBox( + state = state, + enableDismissFromStartToEnd = false, + onDismiss = { if (it == SwipeToDismissBoxValue.EndToStart) archive(row) }, + backgroundContent = { + Surface(Modifier.fillMaxSize(), shape = shape, color = MaterialTheme.colorScheme.tertiaryContainer) { + Box(Modifier.padding(end = 28.dp), contentAlignment = Alignment.CenterEnd) { + ZIcon(ZIcons.Archive, "Archive", tint = MaterialTheme.colorScheme.onTertiaryContainer) + } + } + }, + ) { + SessionItem(row, index, count, model, onOpen, archive) + } +} + +/** The list card colour: paper-white in light, raised in dark. */ +@Composable +fun cardColor() = if (LocalDarkTheme.current) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surfaceContainerLowest + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SessionItem( + row: SessionRow, + index: Int, + count: Int, + model: AppModel, + onOpen: (String) -> Unit, + archive: (SessionRow) -> Unit, +) { + var menu by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + Box { + SegmentedListItem( + onClick = { onOpen(row.id) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menu = true + }, + shapes = segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { HarnessTile(row) }, + supportingContent = { Subline(row) }, + trailingContent = { + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) { + StatusLabel(row) + row.pullRequest?.let { PrBadge(it.number, it.state) } + } + }, + ) { + Text(row.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + ActionMenu( + menu, + { menu = false }, + listOf( + MenuAction(if (row.pinned) "Unpin" else "Pin", ZIcons.Pin) { model.setPinned(row.id, !row.pinned) }, + MenuAction("Rename", ZIcons.Rename) { renaming = true }, + MenuAction("Archive", ZIcons.Archive) { archive(row) }, + ), + ) + } + if (renaming) RenameDialog(row.title, onDismiss = { renaming = false }) { model.rename(row.id, it) } +} + +/** The harness mark on a tile toned by the session's project. */ +@Composable +private fun HarnessTile(row: SessionRow) { + val tone = ProjectColors.color(row.colorIndex(), LocalDarkTheme.current) + Box( + Modifier.size(48.dp).clip(RoundedCornerShape(16.dp)).background(tone.copy(alpha = if (LocalDarkTheme.current) 0.18f else 0.12f)), + contentAlignment = Alignment.Center, + ) { HarnessMark(row.harness, 24.dp, tint = MaterialTheme.colorScheme.onSurface) } +} + +/** Project monogram + name, then the branch — the desktop sidebar subline. */ +@Composable +private fun Subline(row: SessionRow) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + Row(verticalAlignment = Alignment.CenterVertically) { + val project = row.project + if (project != null) { + ProjectTile(project.name, project.colorIndex.toInt(), 16.dp) + Spacer(Modifier.width(6.dp)) + } + Text( + project?.name ?: row.deviceName ?: "No project", + style = MaterialTheme.typography.bodyMedium, + color = muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + row.branch?.takeIf { it.isNotEmpty() }?.let { branch -> + Spacer(Modifier.width(10.dp)) + ZIcon(ZIcons.Branch, null, Modifier.size(14.dp), tint = muted.copy(alpha = 0.7f)) + Spacer(Modifier.width(3.dp)) + Text(branch, style = MaterialTheme.typography.bodyMedium, color = muted.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +fun PrBadge(number: ULong, state: PullRequestState) { + val tone = when (state) { + PullRequestState.OPEN -> successColor() + PullRequestState.MERGED -> MaterialTheme.colorScheme.primary + PullRequestState.CLOSED -> MaterialTheme.colorScheme.error + } + Surface(shape = RoundedCornerShape(8.dp), color = tone.copy(alpha = 0.12f), contentColor = tone) { + Row(Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + ZIcon(ZIcons.PullRequest, null, Modifier.size(13.dp)) + Spacer(Modifier.width(3.dp)) + Text("$number", style = MaterialTheme.typography.labelMediumEmphasized) + } + } +} + +@Composable +fun RenameDialog(current: String, onDismiss: () -> Unit, onRename: (String) -> Unit) { + var text by remember { mutableStateOf(current) } + AlertDialog( + onDismissRequest = onDismiss, + icon = { ZIcon(ZIcons.Rename, null) }, + title = { Text("Rename session") }, + text = { OutlinedTextField(text, { text = it }, singleLine = true, shape = RoundedCornerShape(16.dp)) }, + confirmButton = { + TextButton(onClick = { onRename(text.trim()); onDismiss() }, enabled = text.isNotBlank()) { Text("Rename") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun EmptyState(filter: Filter) { + Column(Modifier.fillMaxWidth().padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) { + ZIcon(ZIcons.Chat, null, Modifier.size(40.dp), tint = MaterialTheme.colorScheme.outline) + Spacer(Modifier.height(12.dp)) + Text( + when (filter) { + Filter.All -> "No sessions yet" + Filter.NeedsYou -> "Nothing needs you" + Filter.Working -> "No agents working" + Filter.Pinned -> "Nothing pinned" + }, + style = MaterialTheme.typography.titleLarge, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/SettingsScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/SettingsScreen.kt new file mode 100644 index 0000000000..2cbdde03a3 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/SettingsScreen.kt @@ -0,0 +1,261 @@ +package sh.zeron.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.ThemeMode +import sh.zeron.android.design.ZIcon +import sh.zeron.android.design.ZIcons +import uniffi.zeron_core.coreVersion +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SettingsScreen(model: AppModel) { + val appearance by model.appearance.collectAsState() + val workspace by model.workspace.collectAsState() + val devices = workspace?.devices.orEmpty() + val list = androidx.compose.foundation.lazy.rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + state = list, + contentPadding = PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, bottom = 140.dp), + ) { + item { ScreenHeader("Settings", null) } + item { + // Account: a tonal hero card with a shaped monogram. + Surface( + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(64.dp).clip(MaterialShapes.Cookie9Sided.toShape()).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text(model.accountName.take(1).uppercase(), style = MaterialTheme.typography.headlineSmallEmphasized, color = MaterialTheme.colorScheme.onPrimary) + } + Spacer(Modifier.width(16.dp)) + Column { + Text(model.accountName, style = MaterialTheme.typography.titleLargeEmphasized, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text(model.accountDetail, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)) + } + } + } + } + section("Appearance") + item { + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + Surface(shape = segmentedShapes(0, 2).shape, color = cardColor()) { + Column(Modifier.padding(16.dp)) { + Text("Theme", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween)) { + val modes = listOf( + Triple(ThemeMode.System, "System", ZIcons.Monitor), + Triple(ThemeMode.Light, "Light", ZIcons.Sun), + Triple(ThemeMode.Dark, "Dark", ZIcons.Moon), + ) + modes.forEachIndexed { index, (mode, label, icon) -> + ToggleButton( + checked = appearance.mode == mode, + onCheckedChange = { model.setAppearance(appearance.copy(mode = mode)) }, + modifier = Modifier.weight(1f).semantics { role = Role.RadioButton }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + modes.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + ) { + ZIcon(icon, null, Modifier.size(ToggleButtonDefaults.IconSize)) + Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing)) + Text(label) + } + } + } + } + } + SegmentedListItem( + onClick = { model.setAppearance(appearance.copy(dynamicColor = !appearance.dynamicColor)) }, + shapes = segmentedShapes(1, 2), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Magic) }, + supportingContent = { Text("Tint Zeron with your wallpaper's palette") }, + trailingContent = { Switch(appearance.dynamicColor, { model.setAppearance(appearance.copy(dynamicColor = it)) }) }, + ) { Text("Wallpaper colors") } + } + } + section("Wallpaper") + item { WallpaperSettings(model) } + if (devices.isNotEmpty()) { + section("Devices") + item { + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + devices.forEachIndexed { i, device -> + SegmentedListItem( + onClick = {}, + shapes = segmentedShapes(i, devices.size), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { + IconTile( + when { + !device.isExecutionHost -> ZIcons.Phone + device.platform == "linux" -> ZIcons.Server + else -> ZIcons.Laptop + }, + ) + }, + supportingContent = { + Text( + listOfNotNull( + if (device.isSelf) "This device" else if (device.online) "Online" else "Offline", + device.version?.let { "v$it" }, + if (device.sessionCount > 0u) "${device.sessionCount} sessions" else null, + ).joinToString(" · "), + ) + }, + trailingContent = { + Box( + Modifier.size(10.dp).clip(CircleShape).background( + if (device.online || device.isSelf) successColor() else MaterialTheme.colorScheme.outlineVariant, + ), + ) + }, + ) { Text(device.name) } + } + } + } + } + section("About") + item { + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SegmentedListItem( + onClick = {}, + shapes = segmentedShapes(0, 2), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Info) }, + supportingContent = { Text("Zeron for Android · core ${coreVersion()}") }, + ) { Text("Version") } + SegmentedListItem( + onClick = { model.signOut() }, + shapes = segmentedShapes(1, 2), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Logout, MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) }, + ) { Text(if (model.isDemo) "Leave demo" else "Sign out", color = MaterialTheme.colorScheme.error) } + } + } + } + StatusBarScrim(scrolled = list.firstVisibleItemIndex > 0 || list.firstVisibleItemScrollOffset > 0) + } +} + +private fun LazyListScope.section(title: String) { + item { + Text( + title, + style = MaterialTheme.typography.titleSmallEmphasized, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 28.dp, top = 24.dp, bottom = 8.dp), + ) + } +} + +/** A leading glyph on a tonal rounded tile. */ +@Composable +fun IconTile( + icon: Int, + container: Color = MaterialTheme.colorScheme.secondaryContainer, + content: Color = MaterialTheme.colorScheme.onSecondaryContainer, +) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(container), contentAlignment = Alignment.Center) { + ZIcon(icon, null, Modifier.size(22.dp), tint = content) + } +} + +/** Wallpaper: shown behind new sessions and the sessions list (iOS parity). */ +@Composable +private fun WallpaperSettings(model: AppModel) { + val store = model.wallpaper + val state by store.state.collectAsState() + val scope = androidx.compose.runtime.rememberCoroutineScope() + val picker = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia(), + ) { uri -> if (uri != null) scope.launch { store.set(uri, "Photo") } } + var effects by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + val rows = if (state.set) 3 else 1 + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + SegmentedListItem( + onClick = { + picker.launch(androidx.activity.result.PickVisualMediaRequest(androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + shapes = segmentedShapes(0, rows), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Image) }, + supportingContent = { Text(if (state.set) state.name ?: "Photo" else "Shown behind new sessions and the sessions list") }, + ) { Text(if (state.set) "Change wallpaper" else "Choose wallpaper") } + if (state.set) { + Box { + SegmentedListItem( + onClick = { effects = true }, + shapes = segmentedShapes(1, rows), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Magic) }, + supportingContent = { Text("${sh.zeron.android.core.WallpaperStore.label(state.effect)} — ${sh.zeron.android.core.WallpaperStore.detail(state.effect)}") }, + ) { Text("Effect") } + ChoiceMenu(effects, { effects = false }, listOf(MenuSection("Effect", sh.zeron.android.core.WallpaperStore.effects.map { e -> + MenuChoice(sh.zeron.android.core.WallpaperStore.label(e), e == state.effect) { store.setEffect(e) } + }))) + } + SegmentedListItem( + onClick = { store.remove() }, + shapes = segmentedShapes(2, rows), + colors = ListItemDefaults.segmentedColors(containerColor = cardColor()), + leadingContent = { IconTile(ZIcons.Delete, MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) }, + ) { Text("Remove wallpaper", color = MaterialTheme.colorScheme.error) } + } + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/SignInScreen.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/SignInScreen.kt new file mode 100644 index 0000000000..a3fd35a41c --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/SignInScreen.kt @@ -0,0 +1,110 @@ +package sh.zeron.android.ui + +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import sh.zeron.android.R +import sh.zeron.android.core.AppModel + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SignInScreen(model: AppModel) { + val context = LocalContext.current + val error by model.signInError.collectAsState() + val orgs by model.orgChoice.collectAsState() + Column( + Modifier.fillMaxSize().safeDrawingPadding().padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painterResource(R.mipmap.ic_launcher_foreground), + null, + Modifier.size(148.dp).clip(MaterialShapes.Cookie12Sided.toShape()), + ) + Spacer(Modifier.height(32.dp)) + Text("Zeron", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(10.dp)) + Text( + "Follow and steer your coding agents from anywhere.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(48.dp)) + Button( + onClick = { + val url = model.beginSignIn() + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, Uri.parse(url)) + }, + modifier = Modifier.fillMaxWidth().heightIn(min = ButtonDefaults.MediumContainerHeight), + shapes = ButtonDefaults.shapes(), + contentPadding = ButtonDefaults.contentPaddingFor(ButtonDefaults.MediumContainerHeight), + ) { + Text("Sign in", style = ButtonDefaults.textStyleFor(ButtonDefaults.MediumContainerHeight)) + } + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { model.startDemo() }, + modifier = Modifier.fillMaxWidth().heightIn(min = ButtonDefaults.MediumContainerHeight), + shapes = ButtonDefaults.shapes(), + ) { + Text("Explore the demo", style = ButtonDefaults.textStyleFor(ButtonDefaults.MediumContainerHeight)) + } + error?.let { + Spacer(Modifier.height(20.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + } + } + orgs?.let { (list, choice) -> + AlertDialog( + onDismissRequest = { choice.complete(null) }, + title = { Text("Choose an organization") }, + text = { + Column { + for (org in list) { + ListItem( + headlineContent = { Text(org.name) }, + trailingContent = { + TextButton(onClick = { choice.complete(org) }) { Text("Open") } + }, + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = { choice.complete(null) }) { Text("Cancel") } }, + ) + } +} diff --git a/apps/android/app/src/main/java/sh/zeron/android/ui/ZeronRoot.kt b/apps/android/app/src/main/java/sh/zeron/android/ui/ZeronRoot.kt new file mode 100644 index 0000000000..e60b294e97 --- /dev/null +++ b/apps/android/app/src/main/java/sh/zeron/android/ui/ZeronRoot.kt @@ -0,0 +1,133 @@ +package sh.zeron.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ShortNavigationBar +import androidx.compose.material3.ShortNavigationBarItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.remember +import sh.zeron.android.design.ZIcons +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import sh.zeron.android.core.AppModel +import sh.zeron.android.design.ZeronTheme + +@Composable +fun ZeronRoot(model: AppModel) { + val appearance by model.appearance.collectAsState() + ZeronTheme(appearance) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + val client by model.client.collectAsState() + AnimatedContent(client != null, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "root") { signedIn -> + if (signedIn) MainNav(model) else SignInScreen(model) + } + } + } +} + +object Routes { + const val HOME = "home" + const val CHAT = "chat/{id}" + const val NEW = "new" + const val SEARCH = "search" + fun chat(id: String) = "chat/$id" +} + +@Composable +private fun MainNav(model: AppModel) { + val nav = rememberNavController() + LaunchedEffect(Unit) { + when (val route = model.launch.route) { + null -> Unit + "new" -> nav.navigate(Routes.NEW) + "search" -> nav.navigate(Routes.SEARCH) + else -> if (route.startsWith("chat:")) nav.navigate(Routes.chat(route.removePrefix("chat:"))) + } + } + NavHost(nav, startDestination = Routes.HOME) { + composable(Routes.HOME) { Home(model, nav) } + composable(Routes.CHAT) { entry -> + val id = entry.arguments?.getString("id") ?: return@composable + SessionScreen(model, id, onBack = { nav.popBackStack() }) + } + composable(Routes.NEW) { + NewSessionScreen(model, onClose = { nav.popBackStack() }, onCreated = { id -> + nav.popBackStack() + nav.navigate(Routes.chat(id)) + }) + } + composable(Routes.SEARCH) { + SearchScreen(model, onBack = { nav.popBackStack() }, onOpen = { nav.navigate(Routes.chat(it)) }) + } + } +} + +private enum class Tab { Sessions, Settings } + +@Composable +private fun Home(model: AppModel, nav: NavHostController) { + var tab by rememberSaveable { mutableStateOf(if (model.launch.route == "settings") Tab.Settings else Tab.Sessions) } + val workspace by model.workspace.collectAsState() + val summary = remember(workspace) { workspace?.let { liveSummary(it) } } + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + when (tab) { + Tab.Sessions -> SessionsScreen(model, onOpen = { nav.navigate(Routes.chat(it)) }) + Tab.Settings -> SettingsScreen(model) + } + // Floating chrome over a soft scrim: new session, then the nav capsule. + Column( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Brush.verticalGradient(0f to Color.Transparent, 0.25f to MaterialTheme.colorScheme.background.copy(alpha = 0.94f), 0.6f to MaterialTheme.colorScheme.background)) + .navigationBarsPadding() + .padding(top = 28.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (tab == Tab.Sessions) NewSessionBar(summary, onClick = { nav.navigate(Routes.NEW) }) + FloatingNavBar( + listOf( + NavItem("Sessions", ZIcons.TabSessions, tab == Tab.Sessions) { tab = Tab.Sessions }, + NavItem("Settings", ZIcons.TabSettings, tab == Tab.Settings) { tab = Tab.Settings }, + ), + trailing = { + TonalCircleButton(ZIcons.Search, "Search", onClick = { nav.navigate(Routes.SEARCH) }, size = 72.dp) + }, + ) + } + } +} diff --git a/apps/android/app/src/main/res/drawable/ic_launcher_inset.xml b/apps/android/app/src/main/res/drawable/ic_launcher_inset.xml new file mode 100644 index 0000000000..f7f87d971b --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_launcher_inset.xml @@ -0,0 +1,4 @@ + + diff --git a/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000000..2aa2d58219 --- /dev/null +++ b/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..886293bab3 Binary files /dev/null and b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/android/app/src/main/res/values-night/colors.xml b/apps/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..d979d3d965 --- /dev/null +++ b/apps/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #060606 + diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f77d354f93 --- /dev/null +++ b/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #F3F3F5 + #12052B + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..20413e7b73 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Zeron + diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..aba0d1cbf2 --- /dev/null +++ b/apps/android/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts new file mode 100644 index 0000000000..8dacf8ce3a --- /dev/null +++ b/apps/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +} diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties new file mode 100644 index 0000000000..3ed5bca253 --- /dev/null +++ b/apps/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +android.useAndroidX=true +kotlin.code.style=official diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml new file mode 100644 index 0000000000..2d7efe5f6e --- /dev/null +++ b/apps/android/gradle/libs.versions.toml @@ -0,0 +1,37 @@ +[versions] +agp = "9.2.1" +kotlin = "2.4.0" +composeBom = "2026.06.00" +material3 = "1.5.0-alpha22" +activityCompose = "1.13.0" +coreKtx = "1.19.0" +lifecycle = "2.11.0" +navigationCompose = "2.9.8" +coroutines = "1.11.0" +jna = "5.19.1" +browser = "1.10.0" +coreSplashscreen = "1.2.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version = "1.7.8" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/apps/android/gradle/wrapper/gradle-wrapper.jar b/apps/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..b1b8ef56b4 Binary files /dev/null and b/apps/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..116d9acdb4 --- /dev/null +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/apps/android/gradlew b/apps/android/gradlew new file mode 100755 index 0000000000..b9bb139f79 --- /dev/null +++ b/apps/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/apps/android/gradlew.bat b/apps/android/gradlew.bat new file mode 100644 index 0000000000..aa5f10b069 --- /dev/null +++ b/apps/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/apps/android/screenshots/attach-staged.png b/apps/android/screenshots/attach-staged.png new file mode 100644 index 0000000000..e4ee995a6f Binary files /dev/null and b/apps/android/screenshots/attach-staged.png differ diff --git a/apps/android/screenshots/chat.png b/apps/android/screenshots/chat.png new file mode 100644 index 0000000000..01d4bffbaa Binary files /dev/null and b/apps/android/screenshots/chat.png differ diff --git a/apps/android/screenshots/composer-focused.png b/apps/android/screenshots/composer-focused.png new file mode 100644 index 0000000000..da88d1ad2d Binary files /dev/null and b/apps/android/screenshots/composer-focused.png differ diff --git a/apps/android/screenshots/dark-chat.png b/apps/android/screenshots/dark-chat.png new file mode 100644 index 0000000000..7edf4caa1e Binary files /dev/null and b/apps/android/screenshots/dark-chat.png differ diff --git a/apps/android/screenshots/dark-new-session.png b/apps/android/screenshots/dark-new-session.png new file mode 100644 index 0000000000..75a79dd890 Binary files /dev/null and b/apps/android/screenshots/dark-new-session.png differ diff --git a/apps/android/screenshots/dark-sessions.png b/apps/android/screenshots/dark-sessions.png new file mode 100644 index 0000000000..251d1b7980 Binary files /dev/null and b/apps/android/screenshots/dark-sessions.png differ diff --git a/apps/android/screenshots/mention-sent.png b/apps/android/screenshots/mention-sent.png new file mode 100644 index 0000000000..bc8256b8e3 Binary files /dev/null and b/apps/android/screenshots/mention-sent.png differ diff --git a/apps/android/screenshots/mention-suggestions.png b/apps/android/screenshots/mention-suggestions.png new file mode 100644 index 0000000000..09eb1a1a58 Binary files /dev/null and b/apps/android/screenshots/mention-suggestions.png differ diff --git a/apps/android/screenshots/new-session.png b/apps/android/screenshots/new-session.png new file mode 100644 index 0000000000..e94e4d8b67 Binary files /dev/null and b/apps/android/screenshots/new-session.png differ diff --git a/apps/android/screenshots/project-sheet.png b/apps/android/screenshots/project-sheet.png new file mode 100644 index 0000000000..2f76562334 Binary files /dev/null and b/apps/android/screenshots/project-sheet.png differ diff --git a/apps/android/screenshots/queue-edited.png b/apps/android/screenshots/queue-edited.png new file mode 100644 index 0000000000..781938f34b Binary files /dev/null and b/apps/android/screenshots/queue-edited.png differ diff --git a/apps/android/screenshots/queue-editing.png b/apps/android/screenshots/queue-editing.png new file mode 100644 index 0000000000..d5dc41e9da Binary files /dev/null and b/apps/android/screenshots/queue-editing.png differ diff --git a/apps/android/screenshots/queue-menu.png b/apps/android/screenshots/queue-menu.png new file mode 100644 index 0000000000..627b7e7d71 Binary files /dev/null and b/apps/android/screenshots/queue-menu.png differ diff --git a/apps/android/screenshots/queued.png b/apps/android/screenshots/queued.png new file mode 100644 index 0000000000..d2cefc9865 Binary files /dev/null and b/apps/android/screenshots/queued.png differ diff --git a/apps/android/screenshots/runway.png b/apps/android/screenshots/runway.png new file mode 100644 index 0000000000..afe749ae31 Binary files /dev/null and b/apps/android/screenshots/runway.png differ diff --git a/apps/android/screenshots/search-results.png b/apps/android/screenshots/search-results.png new file mode 100644 index 0000000000..97f2c67459 Binary files /dev/null and b/apps/android/screenshots/search-results.png differ diff --git a/apps/android/screenshots/search.png b/apps/android/screenshots/search.png new file mode 100644 index 0000000000..ddc685a76a Binary files /dev/null and b/apps/android/screenshots/search.png differ diff --git a/apps/android/screenshots/sessions-scrolled.png b/apps/android/screenshots/sessions-scrolled.png new file mode 100644 index 0000000000..70e6ae05b8 Binary files /dev/null and b/apps/android/screenshots/sessions-scrolled.png differ diff --git a/apps/android/screenshots/sessions.png b/apps/android/screenshots/sessions.png new file mode 100644 index 0000000000..177a3dfabb Binary files /dev/null and b/apps/android/screenshots/sessions.png differ diff --git a/apps/android/screenshots/settings.png b/apps/android/screenshots/settings.png new file mode 100644 index 0000000000..224d6fb7e3 Binary files /dev/null and b/apps/android/screenshots/settings.png differ diff --git a/apps/android/screenshots/streaming.png b/apps/android/screenshots/streaming.png new file mode 100644 index 0000000000..b9fb5d49aa Binary files /dev/null and b/apps/android/screenshots/streaming.png differ diff --git a/apps/android/screenshots/transcript-scrolled.png b/apps/android/screenshots/transcript-scrolled.png new file mode 100644 index 0000000000..d20f07538e Binary files /dev/null and b/apps/android/screenshots/transcript-scrolled.png differ diff --git a/apps/android/screenshots/upload-ring.png b/apps/android/screenshots/upload-ring.png new file mode 100644 index 0000000000..5c510fd358 Binary files /dev/null and b/apps/android/screenshots/upload-ring.png differ diff --git a/apps/android/screenshots/wallpaper-dark-new-session.png b/apps/android/screenshots/wallpaper-dark-new-session.png new file mode 100644 index 0000000000..3c1013c0f2 Binary files /dev/null and b/apps/android/screenshots/wallpaper-dark-new-session.png differ diff --git a/apps/android/screenshots/wallpaper-dark-sessions.png b/apps/android/screenshots/wallpaper-dark-sessions.png new file mode 100644 index 0000000000..26136ce1ae Binary files /dev/null and b/apps/android/screenshots/wallpaper-dark-sessions.png differ diff --git a/apps/android/screenshots/wallpaper-effect-menu.png b/apps/android/screenshots/wallpaper-effect-menu.png new file mode 100644 index 0000000000..47f4ebb5b2 Binary files /dev/null and b/apps/android/screenshots/wallpaper-effect-menu.png differ diff --git a/apps/android/screenshots/wallpaper-halftone.png b/apps/android/screenshots/wallpaper-halftone.png new file mode 100644 index 0000000000..aae9063b8d Binary files /dev/null and b/apps/android/screenshots/wallpaper-halftone.png differ diff --git a/apps/android/screenshots/wallpaper-new-session.png b/apps/android/screenshots/wallpaper-new-session.png new file mode 100644 index 0000000000..509306fb69 Binary files /dev/null and b/apps/android/screenshots/wallpaper-new-session.png differ diff --git a/apps/android/screenshots/wallpaper-sessions.png b/apps/android/screenshots/wallpaper-sessions.png new file mode 100644 index 0000000000..28f7a3f2c2 Binary files /dev/null and b/apps/android/screenshots/wallpaper-sessions.png differ diff --git a/apps/android/screenshots/wallpaper-settings.png b/apps/android/screenshots/wallpaper-settings.png new file mode 100644 index 0000000000..8978336cbd Binary files /dev/null and b/apps/android/screenshots/wallpaper-settings.png differ diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts new file mode 100644 index 0000000000..4122e9d9fc --- /dev/null +++ b/apps/android/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Zeron" +include(":app") diff --git a/crates/mobile/uniffi.toml b/crates/mobile/uniffi.toml new file mode 100644 index 0000000000..745fc26d24 --- /dev/null +++ b/crates/mobile/uniffi.toml @@ -0,0 +1,14 @@ +# Kotlin-only renames (the Swift bindings are unaffected): Kotlin exceptions +# already own `message`, and AutoCloseable already owns `close()`. +[bindings.kotlin.rename] +"TranscriptView.close" = "shutdown" +"CoreError.NotFound.message" = "reason" +"CoreError.InvalidArgument.message" = "reason" +"CoreError.HostUnavailable.message" = "reason" +"CoreError.Unsupported.message" = "reason" +"CoreError.HostError.message" = "reason" +"CoreError.Network.message" = "reason" +"CoreError.Auth.message" = "reason" +"CoreError.Storage.message" = "reason" +"CoreError.NotImplemented.message" = "reason" +"CoreError.Internal.message" = "reason" diff --git a/scripts/android/build-core.sh b/scripts/android/build-core.sh index 8208536d1d..77d35ddcbe 100755 --- a/scripts/android/build-core.sh +++ b/scripts/android/build-core.sh @@ -7,7 +7,9 @@ # Kotlin generation needs only the host toolchain. The .so build needs the # Android NDK + cargo-ndk (`cargo install cargo-ndk`, # `rustup target add aarch64-linux-android x86_64-linux-android`); it is -# skipped with a note when they are missing. (Not yet exercised in CI.) +# skipped with a note when they are missing. Run by the app's `buildCore` +# Gradle task (apps/android). `ZERON_ANDROID_ABIS` picks the ABIs (default +# arm64-v8a x86_64). set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" @@ -23,7 +25,9 @@ HOST_LIB="$ROOT/target/mobile/libzeron_mobile.$([[ "$(uname)" == Darwin ]] && ec echo "kotlin bindings: $OUT/kotlin" if command -v cargo-ndk >/dev/null && [[ -n "${ANDROID_NDK_HOME:-}" ]]; then - cargo ndk -t arm64-v8a -t x86_64 -o "$OUT/jniLibs" \ + ABIS=() + for abi in ${ZERON_ANDROID_ABIS:-arm64-v8a x86_64}; do ABIS+=(-t "$abi"); done + cargo ndk "${ABIS[@]}" -o "$OUT/jniLibs" \ build --locked -p zeron-mobile --lib --profile mobile echo "jniLibs: $OUT/jniLibs" else diff --git a/scripts/android/gen-icons.sh b/scripts/android/gen-icons.sh new file mode 100755 index 0000000000..8d1a8be1bb --- /dev/null +++ b/scripts/android/gen-icons.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Rasterize the transcript's tool and file icons (the iOS asset catalog's +# SVGs — one source for both apps) into PNG assets for the Android painter. +# +# scripts/android/gen-icons.sh +# +# The line icons (desktop set, tab and tool glyphs) become VectorDrawables +# (svg2vd.py) so they tint and scale like any Android icon; file-type icons +# (multi-color) are rasterized. +# +# Needs rsvg-convert (`brew install librsvg`); without it the painter falls +# back to Material symbols. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$1" +RES="$2" +mkdir -p "$OUT" "$RES" +python3 "$ROOT/scripts/android/svg2vd.py" "$RES" +if ! command -v rsvg-convert >/dev/null; then + echo "note: rsvg-convert not found — skipped icon rasterization" + exit 0 +fi +for svg in "$ROOT"/apps/ios/Zeron/Assets.xcassets/{ToolIcons,FileIcons}/*.imageset/*.svg; do + name="$(basename "$(dirname "$svg")" .imageset)" + png="$OUT/$name.png" + [[ "$png" -nt "$svg" ]] && continue + rsvg-convert -w 72 -h 72 --keep-aspect-ratio "$svg" -o "$png" +done diff --git a/scripts/android/svg2vd.py b/scripts/android/svg2vd.py new file mode 100755 index 0000000000..981c0c4488 --- /dev/null +++ b/scripts/android/svg2vd.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Convert Zeron's line icons (desktop crates/ui/assets/icons, iOS tab and +tool icons) into Android VectorDrawables, so every platform draws the same +glyphs. Handles the subset those icons use: ///, +inherited fill/stroke attributes, caps, joins and even-odd fills. + + scripts/android/svg2vd.py +""" +import glob +import os +import re +import sys +import xml.etree.ElementTree as ET + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SOURCES = [ + ("", os.path.join(ROOT, "crates/ui/assets/icons/*.svg")), + ("", os.path.join(ROOT, "apps/ios/Zeron/Assets.xcassets/TabIcons/*/*.svg")), + ("", os.path.join(ROOT, "apps/ios/Zeron/Assets.xcassets/ToolIcons/*/*.svg")), +] +# Brand marks are drawn from path data at runtime (their own colors). +SKIP = re.compile(r"-mark$|^zeron-logo$") +INHERITED = ("fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", + "fill-rule", "fill-opacity", "stroke-opacity", "opacity") + + +def tag(el): + return el.tag.split("}")[-1] + + +def color(v): + if v is None or v == "none": + return None + # Everything is a single-ink glyph tinted by the caller. + return "#FF000000" + + +def num(v, default=0.0): + try: + return float(re.sub(r"[a-z%]+$", "", v)) if v is not None else default + except ValueError: + return default + + +def f(x): + return ("%.4f" % x).rstrip("0").rstrip(".") + + +def circle_path(cx, cy, r): + return f"M{f(cx - r)},{f(cy)}a{f(r)},{f(r)} 0 1,0 {f(2 * r)},0a{f(r)},{f(r)} 0 1,0 {f(-2 * r)},0Z" + + +def rect_path(x, y, w, h, rx, ry): + if rx <= 0 and ry <= 0: + return f"M{f(x)},{f(y)}h{f(w)}v{f(h)}h{f(-w)}Z" + rx = min(rx or ry, w / 2) + ry = min(ry or rx, h / 2) + return (f"M{f(x + rx)},{f(y)}h{f(w - 2 * rx)}a{f(rx)},{f(ry)} 0 0 1 {f(rx)},{f(ry)}" + f"v{f(h - 2 * ry)}a{f(rx)},{f(ry)} 0 0 1 {f(-rx)},{f(ry)}h{f(-(w - 2 * rx))}" + f"a{f(rx)},{f(ry)} 0 0 1 {f(-rx)},{f(-ry)}v{f(-(h - 2 * ry))}a{f(rx)},{f(ry)} 0 0 1 {f(rx)},{f(-ry)}Z") + + +def style_attrs(el): + out = {k: el.get(k) for k in INHERITED if el.get(k) is not None} + for decl in (el.get("style") or "").split(";"): + if ":" in decl: + k, v = decl.split(":", 1) + if k.strip() in INHERITED: + out[k.strip()] = v.strip() + return out + + +def walk(el, inherited, paths): + attrs = dict(inherited) + attrs.update(style_attrs(el)) + t = tag(el) + d = None + if t == "path": + d = el.get("d") + elif t == "circle": + d = circle_path(num(el.get("cx")), num(el.get("cy")), num(el.get("r"))) + elif t == "rect": + d = rect_path(num(el.get("x")), num(el.get("y")), num(el.get("width")), num(el.get("height")), + num(el.get("rx")), num(el.get("ry"))) + if d: + paths.append((d, attrs)) + if t in ("svg", "g"): + for child in el: + walk(child, attrs, paths) + + +def convert(svg_path): + root = ET.parse(svg_path).getroot() + vb = [float(v) for v in re.split(r"[ ,]+", (root.get("viewBox") or "0 0 24 24").strip())] + paths = [] + walk(root, {"fill": "#000"}, paths) + lines = [ + '', + ] + if vb[0] or vb[1]: + lines.append(f' ') + for d, a in paths: + attrs = [f'android:pathData="{d}"'] + fill = color(a.get("fill")) + stroke = color(a.get("stroke")) + if fill: + attrs.append(f'android:fillColor="{fill}"') + alpha = num(a.get("fill-opacity"), 1.0) * num(a.get("opacity"), 1.0) + if alpha < 1: + attrs.append(f'android:fillAlpha="{f(alpha)}"') + if a.get("fill-rule") == "evenodd": + attrs.append('android:fillType="evenOdd"') + if stroke: + attrs.append(f'android:strokeColor="{stroke}"') + attrs.append(f'android:strokeWidth="{f(num(a.get("stroke-width"), 1.0))}"') + cap = a.get("stroke-linecap") + if cap in ("round", "square", "butt"): + attrs.append(f'android:strokeLineCap="{cap}"') + join = a.get("stroke-linejoin") + if join in ("round", "bevel", "miter"): + attrs.append(f'android:strokeLineJoin="{join}"') + alpha = num(a.get("stroke-opacity"), 1.0) * num(a.get("opacity"), 1.0) + if alpha < 1: + attrs.append(f'android:strokeAlpha="{f(alpha)}"') + if not fill and not stroke: + continue + lines.append(" ") + if vb[0] or vb[1]: + lines.append(" ") + lines.append("") + return "\n".join(lines) + "\n" + + +def main(): + out = os.path.join(sys.argv[1], "drawable") + os.makedirs(out, exist_ok=True) + count = 0 + for _, pattern in SOURCES: + for svg in sorted(glob.glob(pattern)): + name = os.path.splitext(os.path.basename(svg))[0] + if SKIP.search(name): + continue + res = "zi_" + re.sub(r"[^a-z0-9]+", "_", name.lower()) + with open(os.path.join(out, res + ".xml"), "w") as fh: + fh.write(convert(svg)) + count += 1 + print(f"vector icons: {count}") + + +if __name__ == "__main__": + main()