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:
1 parent
de3b5fd956
commit
c668b209b2
11 files changed
+634
-14
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,9 +53,33 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
val theme by controller.theme.collectAsState()
|
||||
|
||||
IrisTheme {
|
||||
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||
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 ->
|
||||
@@ -68,4 +98,6 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +355,126 @@ 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
|
||||
@@ -70,3 +77,53 @@ 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()))
|
||||
|
||||
Reference in new issue
Block a user