User theme: customizable bubble colors + background (color/image) in Settings → Appearance

- UserTheme model (ARGB ints) + LocalUserTheme, persisted via SecureStore
- HSV color picker dialog (Apply keeps open, OK applies+closes, Cancel)
- Background: solid color or image (SAF on Android, JFileChooser on desktop)
- Reasoning block: 0.6 black overlay on agent bubble color (adapts automatically)
- Contrast-aware text on bubbles + pinned contentColor on root Surface
This commit is contained in:
ARIA committed 2026-08-20 19:14:35 +02:00
1 parent 86a4c8ee70
commit 0e8f69a43c
11 files changed
+650 -30

No files matched your search

@@ -82,6 +82,31 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
}
}
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
}
try {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val name = queryDisplayName(context, uri) ?: "background"
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
val dir = File(context.filesDir, "theme").apply { mkdirs() }
val target = File(dir, "background${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
}
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
}
private fun queryDisplayName(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -102,6 +127,22 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
// inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
@Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath
@@ -6,6 +6,7 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import iris.data.SecureStore
import iris.ui.theme.UserTheme
import java.util.UUID
/**
@@ -113,6 +114,26 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
override var userBubbleColor: Int
get() = prefs.getInt(KEY_USER_BUBBLE_COLOR, UserTheme.DEFAULT_USER_BUBBLE)
set(value) = prefs.edit().putInt(KEY_USER_BUBBLE_COLOR, value).apply()
override var agentBubbleColor: Int
get() = prefs.getInt(KEY_AGENT_BUBBLE_COLOR, UserTheme.DEFAULT_AGENT_BUBBLE)
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int
get() = prefs.getInt(KEY_BACKGROUND_COLOR, UserTheme.DEFAULT_BACKGROUND)
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
@@ -136,5 +157,10 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
const val KEY_BACKGROUND_MODE = "background_mode"
const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
}
}
@@ -1,14 +1,18 @@
package iris
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
@@ -17,6 +21,8 @@ import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
/**
* Root composable shared by the Android and Desktop shells.
@@ -47,24 +53,50 @@ fun IrisApp(
}
}
val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState()
IrisTheme {
Surface(modifier = Modifier.background(IrisColors.background)) {
val s = state
when (s) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
// Connecting / Reconnecting / Connected all render the chat; the header
// StatusChip + connection banner show the link state without
// blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
CompositionLocalProvider(LocalUserTheme provides theme) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through.
// contentColor is pinned to the app's light text color — Surface
// would otherwise derive it from the user's background (black
// text on a light background), which would also darken text that
// sits on the app's own dark panels.
Surface(
modifier = Modifier.fillMaxSize(),
color = theme.background,
contentColor = IrisColors.onBackground,
) {
val bgImage = rememberBackgroundImage(theme)
Box(modifier = Modifier.fillMaxSize()) {
val bmp = bgImage
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
val s = state
when (s) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
// Connecting / Reconnecting / Connected all render the chat; the header
// StatusChip + connection banner show the link state without
// blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
}
}
}
}
}
@@ -42,6 +42,21 @@ interface SecureStore {
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean
/** UI setting: user bubble color (ARGB int). */
var userBubbleColor: Int
/** UI setting: agent bubble color (ARGB int). */
var agentBubbleColor: Int
/** UI setting: background mode — "color" or "image". */
var backgroundMode: String
/** UI setting: background color (ARGB int). */
var backgroundColor: Int
/** UI setting: background image path (empty when unused). */
var backgroundImagePath: String
fun savePairing(url: String, token: String)
fun clear()
}
@@ -20,12 +20,23 @@ data class PickedFile(
@Composable
expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit)
/**
* Image-only file picker (SAF on Android, FileChooser on desktop). The picked
* image is copied into the app's persistent storage; [onPicked] receives the
* staged file, or null when the user cancels.
*/
@Composable
expect fun ImageFilePicker(onPicked: (PickedFile?) -> Unit)
/** Base directory for the media cache (app-specific cache dir). */
expect fun mediaCacheBaseDir(): String
/** Load an image from a local path for display. Null on failure. */
expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
@@ -62,6 +62,8 @@ import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -158,6 +160,48 @@ class IrisController(
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
}
// ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme).
private val _theme = MutableStateFlow(
UserTheme(
userBubbleColor = store.userBubbleColor,
agentBubbleColor = store.agentBubbleColor,
backgroundMode = BackgroundMode.entries.firstOrNull {
it.name.equals(store.backgroundMode, ignoreCase = true)
} ?: BackgroundMode.Color,
backgroundColor = store.backgroundColor,
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
)
)
val theme: StateFlow<UserTheme> = _theme.asStateFlow()
fun setUserBubbleColor(argb: Int) = updateTheme { it.copy(userBubbleColor = argb) }
fun setAgentBubbleColor(argb: Int) = updateTheme { it.copy(agentBubbleColor = argb) }
fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) }
fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) }
fun setBackgroundImagePath(path: String?) = updateTheme { it.copy(backgroundImagePath = path) }
/** Clears the background image and falls back to the solid color. */
fun removeBackgroundImage() =
updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
fun resetTheme() = updateTheme { UserTheme() }
private fun updateTheme(transform: (UserTheme) -> UserTheme) {
val t = transform(_theme.value)
_theme.value = t
store.userBubbleColor = t.userBubbleColor
store.agentBubbleColor = t.agentBubbleColor
store.backgroundMode = t.backgroundMode.name.lowercase()
store.backgroundColor = t.backgroundColor
store.backgroundImagePath = t.backgroundImagePath.orEmpty()
}
// ── M3: search state ──────────────────────────────────────────────────
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
@@ -107,7 +107,9 @@ import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.rightClick
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.avatarColor
import iris.ui.theme.contrastText
import iris.util.formatDayLabel
import iris.util.formatTime
import iris.util.localDayKey
@@ -1530,15 +1532,16 @@ private fun MessageBubble(
) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
val userTheme = LocalUserTheme.current
val bubbleColor = when {
isUser -> IrisColors.bubbleUser
isUser -> userTheme.userBubble
isCommentary -> IrisColors.bubbleCommentary
else -> IrisColors.bubbleAssistant
else -> userTheme.agentBubble
}
val textColor = when {
isUser -> Color.White
isUser -> userTheme.userBubble.contrastText()
isCommentary -> IrisColors.onSurface.copy(alpha = 0.55f)
else -> IrisColors.onSurface
else -> userTheme.agentBubble.contrastText()
}
val time = formatTime(msg.ts)
Row(
@@ -1575,7 +1578,7 @@ private fun MessageBubble(
) {
// Reasoning block above the answer (assistant, non-commentary).
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse)
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
Spacer(modifier = Modifier.height(6.dp))
}
if (isUser) {
@@ -1852,17 +1855,27 @@ private fun AttachmentChip(att: IrisController.PendingAttachment, onRemove: () -
* expanded. Tap to toggle.
*/
@Composable
private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean, bubbleColor: Color) {
val clipboard = LocalClipboardManager.current
var expanded by remember(autoCollapse) {
mutableStateOf(!autoCollapse || reasoning.length <= 240)
}
var copied by remember { mutableStateOf(false) }
// A semi-transparent black overlay darkens whatever the user's agent bubble
// color is, so the reasoning block always matches the bubble while staying
// visually distinct from the message content. Text is derived from the
// effective (darkened) background so it stays readable on any bubble color.
val effective = Color(
red = bubbleColor.red * 0.4f,
green = bubbleColor.green * 0.4f,
blue = bubbleColor.blue * 0.4f,
)
val textColor = effective.contrastText()
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.reasoningPanel)
.background(Color.Black.copy(alpha = 0.6f))
.padding(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1870,7 +1883,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
"💭 Reasoning",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = IrisColors.textSecondary,
color = textColor,
modifier = Modifier
.weight(1f)
.clickable { expanded = !expanded },
@@ -1884,7 +1897,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
Text(
if (expanded) "▾" else "▸",
fontSize = 12.sp,
color = IrisColors.textSecondary,
color = textColor,
modifier = Modifier.clickable { expanded = !expanded },
)
}
@@ -1893,7 +1906,7 @@ private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
reasoning,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = IrisColors.textMuted,
color = textColor.copy(alpha = 0.75f),
)
}
}
@@ -1,7 +1,10 @@
package iris.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,36 +19,56 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.platform.ImageFilePicker
import iris.platform.loadScaledImage
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import kotlin.math.roundToInt
/**
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
/** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
Box(modifier = Modifier.fillMaxSize().background(IrisColors.background)) {
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -126,6 +149,117 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}
}
Text(
"Appearance",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("💬 Your bubble", fontSize = 14.sp)
Text(
"Color of your message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.userBubble) { pickerTarget = PickerTarget.UserBubble }
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🤖 Agent bubble", fontSize = 14.sp)
Text(
"Color of Hermes' message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.agentBubble) { pickerTarget = PickerTarget.AgentBubble }
}
}
SettingsCard {
Text("🖼 Background", fontSize = 14.sp)
Text(
"Solid color or image behind the chat",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TopicChip(
label = "Color",
selected = theme.backgroundMode == BackgroundMode.Color,
onClick = { controller.setBackgroundMode(BackgroundMode.Color) },
)
TopicChip(
label = "Image",
selected = theme.backgroundMode == BackgroundMode.Image,
onClick = { controller.setBackgroundMode(BackgroundMode.Image) },
)
}
if (theme.backgroundMode == BackgroundMode.Color) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Background color", fontSize = 12.sp, color = IrisColors.textDim)
}
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
else fileNameOf(theme.backgroundImagePath!!),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ImageFilePicker { picked ->
picked?.let { controller.setBackgroundImagePath(it.path) }
}
}
if (!theme.backgroundImagePath.isNullOrBlank()) {
TextButton(onClick = { controller.removeBackgroundImage() }) {
Text("Remove image", fontSize = 12.sp)
}
}
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Reset appearance", fontSize = 14.sp)
Text(
"Restore the default colors and background",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
TextButton(onClick = { controller.resetTheme() }) {
Text("Reset", fontSize = 12.sp)
}
}
}
Text(
"Tools",
style = MaterialTheme.typography.titleSmall,
@@ -150,6 +284,40 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}
}
}
when (pickerTarget) {
PickerTarget.UserBubble -> ColorPickerDialog(
title = "Your bubble",
initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) },
onConfirm = {
controller.setUserBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.AgentBubble -> ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
onConfirm = {
controller.setAgentBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.Background -> ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
onConfirm = {
controller.setBackgroundColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
null -> Unit
}
}
}
@@ -186,4 +354,127 @@ private fun SettingsCard(content: @Composable () -> Unit) {
) {
content()
}
}
}
/** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
)
}
/** Small thumbnail of the current background image (empty panel when none). */
@Composable
private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) {
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
}
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
val b = bmp
if (b != null) {
Image(
bitmap = b,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks;
* [onConfirm] applies and closes; [onDismiss] closes without applying.
*/
@Composable
private fun ColorPickerDialog(
title: String,
initialArgb: Int,
onApply: (Int) -> Unit,
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val initial = remember { argbToHsv(initialArgb) }
var hue by remember { mutableFloatStateOf(initial[0]) }
var sat by remember { mutableFloatStateOf(initial[1]) }
var bri by remember { mutableFloatStateOf(initial[2]) }
val color = Color.hsv(hue, sat, bri)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(color),
)
Spacer(modifier = Modifier.height(12.dp))
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
Slider(value = sat, onValueChange = { sat = it }, valueRange = 0f..1f)
Slider(value = bri, onValueChange = { bri = it }, valueRange = 0f..1f)
Text(
"#%06X".format(color.toArgbInt() and 0xFFFFFF),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
},
confirmButton = {
TextButton(onClick = { onApply(color.toArgbInt()) }) { Text("Apply") }
TextButton(onClick = { onConfirm(color.toArgbInt()) }) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/** Compose Color → ARGB int (no Color.toArgb in common code). */
private fun Color.toArgbInt(): Int {
fun c(v: Float) = (v * 255f).roundToInt().coerceIn(0, 255)
return (c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)
}
/** ARGB int → [hue (deg), saturation, brightness] (alpha ignored). */
private fun argbToHsv(argb: Int): FloatArray {
val r = ((argb shr 16) and 0xFF) / 255f
val g = ((argb shr 8) and 0xFF) / 255f
val b = (argb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
var hue = 0f
if (delta != 0f) {
hue = when (max) {
r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
}
hue *= 60f
if (hue < 0f) hue += 360f
}
val sat = if (max == 0f) 0f else delta / max
return floatArrayOf(hue, sat, max)
}
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String =
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -3,7 +3,15 @@ package iris.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
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.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.loadScaledImage
/**
* Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -34,7 +42,6 @@ object IrisColors {
val chip = Color(0xFF2A2E3B)
val chipSelected = Color(0xFF2A3550)
val rowSelected = Color(0xFF232838)
val reasoningPanel = Color(0xFF1E212B)
val divider = Color(0xFF2A2E3B)
// Status
@@ -69,4 +76,54 @@ fun IrisTheme(content: @Composable () -> Unit) {
fun avatarColor(name: String): Color {
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
return Color.hsv(hue, 0.5f, 0.75f)
}
/**
* User-customizable appearance (Settings → Appearance). Colors are stored as
* ARGB ints so they can be persisted without Compose types.
*/
data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
val backgroundMode: BackgroundMode = BackgroundMode.Color,
val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = null,
) {
val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor)
val background: Color get() = Color(backgroundColor)
companion object {
val DEFAULT_USER_BUBBLE: Int = 0xFF4F7CFF.toInt()
val DEFAULT_AGENT_BUBBLE: Int = 0xFF2A2E3B.toInt()
val DEFAULT_BACKGROUND: Int = 0xFF1B1E28.toInt()
}
}
/** Background style: solid color or image. */
enum class BackgroundMode { Color, Image }
/** Current user theme, provided at the app root (IrisApp). */
val LocalUserTheme = staticCompositionLocalOf { UserTheme() }
/** Black or white, whichever reads better on [this]. */
fun Color.contrastText(): Color {
val luminance = 0.299f * red + 0.587f * green + 0.114f * blue
return if (luminance > 0.6f) Color(0xFF1B1E28) else Color.White
}
/**
* Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Null in color mode or on decode failure — the
* caller then falls back to the solid background color.
*/
@Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val path = theme.backgroundImagePath
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) {
image = if (wantImage) loadScaledImage(path!!, 2048) else null
}
return image
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.MediaItem
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.StandardProtocolFamily
@@ -40,6 +41,7 @@ import java.nio.channels.SocketChannel
import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -93,6 +95,33 @@ private fun pickFile(): PickedFile? {
)
}
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
onPicked(null)
return@Button
}
val f = chooser.selectedFile ?: return@Button
try {
// Copy into the persistent ~/.iris dir so the theme survives
// deletion of the original file.
val dir = File(File(System.getProperty("user.home"), ".iris"), "theme").apply { mkdirs() }
val ext = f.name.substringAfterLast('.', "img").lowercase()
val target = File(dir, "background.$ext")
f.copyTo(target, overwrite = true)
onPicked(PickedFile(target.absolutePath, f.name, guessMime(f.name), target.length()))
} catch (e: Exception) {
onPicked(null)
}
}) {
Text("Choose image…")
}
}
private fun guessMime(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) {
@@ -131,6 +160,26 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
if (scale >= 1f) {
img.toComposeImageBitmap()
} else {
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
scaled.toComposeImageBitmap()
}
} catch (_: Exception) {
null
}
}
/**
* M6: inline media playback backed by mpv (docs/11 §11.2).
*
@@ -2,6 +2,7 @@ package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import iris.ui.theme.UserTheme
import java.io.File
import java.security.SecureRandom
import java.util.UUID
@@ -38,6 +39,11 @@ class DesktopSecureStore : SecureStore {
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = "color",
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = "",
)
init {
@@ -165,6 +171,41 @@ class DesktopSecureStore : SecureStore {
save(d.copy(reasoningAutoCollapse = value))
}
override var userBubbleColor: Int
get() = load().userBubbleColor
set(value) {
val d = load()
save(d.copy(userBubbleColor = value))
}
override var agentBubbleColor: Int
get() = load().agentBubbleColor
set(value) {
val d = load()
save(d.copy(agentBubbleColor = value))
}
override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" }
set(value) {
val d = load()
save(d.copy(backgroundMode = value))
}
override var backgroundColor: Int
get() = load().backgroundColor
set(value) {
val d = load()
save(d.copy(backgroundColor = value))
}
override var backgroundImagePath: String
get() = load().backgroundImagePath
set(value) {
val d = load()
save(d.copy(backgroundImagePath = value))
}
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim()))