Перенос темы с AgentikTheme на ProvideAgentikTheme, рефакторинг стилизации Markdown

This commit is contained in:
2026-09-28 16:28:02 +03:00
parent 9aaf0c3a46
commit 9c2e794a73
7 changed files with 186 additions and 123 deletions
@@ -38,6 +38,8 @@ import pw.binom.agentik.desktop.ui.screens.ConversationList
import pw.binom.agentik.desktop.ui.screens.GroupsDialog import pw.binom.agentik.desktop.ui.screens.GroupsDialog
import pw.binom.agentik.desktop.ui.screens.NewChatDialog import pw.binom.agentik.desktop.ui.screens.NewChatDialog
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
private sealed interface AppDialog { private sealed interface AppDialog {
data object Agents : AppDialog data object Agents : AppDialog
@@ -52,7 +54,7 @@ private sealed interface AppDialog {
*/ */
@Composable @Composable
fun App(state: AppState) { fun App(state: AppState) {
AgentikTheme(themeId = state.settingsState.themeId) { ProvideAgentikTheme(theme = state.settingsState.themeId.toAgentikTheme()) {
CompositionLocalProvider(LocalAvatarStorage provides state.avatars) { CompositionLocalProvider(LocalAvatarStorage provides state.avatars) {
LaunchedEffect(Unit) { state.start() } LaunchedEffect(Unit) { state.start() }
Root(state) Root(state)
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.ui.ClientImageSupport import pw.binom.agentik.desktop.ui.ClientImageSupport
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.util.Base64 import java.util.Base64
private enum class ImageLoadState { Loading, Ready, Error } private enum class ImageLoadState { Loading, Ready, Error }
@@ -41,6 +42,7 @@ fun ChatImage(src: String, alt: String?) {
// Декодер не зарегистрирован — fallback: alt (или ссылка) текстом. // Декодер не зарегистрирован — fallback: alt (или ссылка) текстом.
Text( Text(
text = alt?.takeIf { it.isNotBlank() } ?: src, text = alt?.takeIf { it.isNotBlank() } ?: src,
color = AgentikTheme.colors.text,
modifier = Modifier.widthIn(max = 280.dp), modifier = Modifier.widthIn(max = 280.dp),
) )
return return
@@ -18,12 +18,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -38,52 +35,55 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.markdown.MarkdownSegment import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.TextStyleSpan import pw.binom.agentik.desktop.markdown.TextStyleSpan
import pw.binom.agentik.desktop.markdown.parseMarkdown import pw.binom.agentik.desktop.markdown.parseMarkdown
/** Скругление у блоков (код, таблица). Геометрия, а не тема — поэтому константа. */
private val RoundedBlockShape = RoundedCornerShape(8.dp)
/** Ширина колонки таблицы ≈ [TableCharWidth] на символ, но не уже [TableMinColumnWidth]. */
private val TableCharWidth = 9.dp
private val TableMinColumnWidth = 60.dp
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public rendering entry point // Public rendering entry point
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Parse markdown and render segments with the given base text style. * Разбирает markdown и рисует сегменты стилем [style] (по умолчанию — текущий
* стиль темы из [LocalMarkdownStyle]).
*/ */
@Composable @Composable
fun renderMarkdownSegments( fun renderMarkdownSegments(
content: String, content: String,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean = false, isBubbleScrolled: Boolean = false,
onUrlClick: (String) -> Unit = {}, onUrlClick: (String) -> Unit = {},
style: MarkdownStyle = LocalMarkdownStyle.current,
) { ) {
val baseStyle = SpanStyle(color = textColor, fontSize = fontSize) val segments = remember(content) { parseMarkdown(content) }
val colors = AgentikTheme.colors
val segments = parseMarkdown(content)
var previousWasText = false var previousWasText = false
for (segment in segments) { for (segment in segments) {
when (segment) { when (segment) {
is MarkdownSegment.Text -> { is MarkdownSegment.Text -> {
val annotated = segment.toAnnotatedString(baseStyle, colors) val annotated = segment.toAnnotatedString(style)
if (annotated.text.isNotEmpty()) { if (annotated.text.isNotEmpty()) {
// Между соседними абзацами (последовательными текстовыми сегментами) // Между соседними абзацами (последовательными текстовыми сегментами)
// оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок. // оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок.
if (previousWasText) { if (previousWasText) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(style.paragraphSpacing))
} }
clickableMarkdownText( clickableMarkdownText(
annotatedString = annotated, annotatedString = annotated,
lineHeight = style.bodyLineHeight,
modifier = Modifier modifier = Modifier
.then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier), .then(if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth) else Modifier),
onUrlClick = onUrlClick, onUrlClick = onUrlClick,
) )
previousWasText = true previousWasText = true
@@ -92,76 +92,79 @@ fun renderMarkdownSegments(
is MarkdownSegment.CodeBlock -> { is MarkdownSegment.CodeBlock -> {
previousWasText = false previousWasText = false
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
Column( Column(
modifier = Modifier modifier = Modifier
.then( .then(
if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth)
else Modifier.fillMaxWidth() else Modifier.fillMaxWidth()
) )
.clip(RoundedCornerShape(8.dp)) .clip(RoundedBlockShape)
.background(colors.codeBackground), .background(style.codeBackground),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(AgentikTheme.colors.panelElevated) .background(style.codeHeaderBackground)
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(
horizontal = style.codeHeaderPaddingHorizontal,
vertical = style.codeHeaderPaddingVertical,
),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = segment.language ?: "Code", text = segment.language ?: "Code",
fontSize = 12.sp, fontSize = style.labelFontSize,
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
color = colors.textDim, color = style.labelColor,
) )
Icon( Icon(
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
contentDescription = "Скопировать", contentDescription = "Скопировать",
modifier = Modifier modifier = Modifier
.size(16.dp) .size(style.copyIconSize)
.clickable { .clickable {
clipboardManager.setText(AnnotatedString(segment.code)) clipboardManager.setText(AnnotatedString(segment.code))
}, },
tint = colors.textDim, tint = style.labelColor,
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(style.codePadding)
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
) { ) {
Text( Text(
text = segment.code, text = segment.code,
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
fontSize = 13.sp, fontSize = style.codeFontSize,
color = colors.codeText, color = style.codeTextColor,
) )
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
is MarkdownSegment.Table -> { is MarkdownSegment.Table -> {
previousWasText = false previousWasText = false
renderTable(segment, textColor, fontSize, isBubbleScrolled) renderTable(segment, style, isBubbleScrolled)
} }
is MarkdownSegment.Image -> { is MarkdownSegment.Image -> {
previousWasText = false previousWasText = false
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
ChatImage(src = segment.src, alt = segment.alt) ChatImage(src = segment.src, alt = segment.alt)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
is MarkdownSegment.ThematicBreak -> { is MarkdownSegment.ThematicBreak -> {
previousWasText = false previousWasText = false
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .height(1.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)), .background(style.divider),
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(style.blockSpacing))
} }
} }
} }
@@ -174,11 +177,10 @@ fun renderMarkdownSegments(
@Composable @Composable
private fun renderTable( private fun renderTable(
segment: MarkdownSegment.Table, segment: MarkdownSegment.Table,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
isBubbleScrolled: Boolean, isBubbleScrolled: Boolean,
) { ) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(style.tableSpacing))
val columnWidths = remember(segment) { val columnWidths = remember(segment) {
val columnCount = segment.headers.size val columnCount = segment.headers.size
@@ -203,15 +205,14 @@ private fun renderTable(
Column( Column(
modifier = Modifier modifier = Modifier
.then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth()) .then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth())
.clip(RoundedCornerShape(8.dp)) .clip(RoundedBlockShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)) .background(style.tableSurface)
) { ) {
if (isBubbleScrolled) { if (isBubbleScrolled) {
TableRows( TableRows(
segment = segment, segment = segment,
columnWidths = columnWidths, columnWidths = columnWidths,
textColor = textColor, style = style,
fontSize = fontSize,
) )
} else { } else {
Box( Box(
@@ -223,30 +224,27 @@ private fun renderTable(
TableRows( TableRows(
segment = segment, segment = segment,
columnWidths = columnWidths, columnWidths = columnWidths,
textColor = textColor, style = style,
fontSize = fontSize,
) )
} }
} }
} }
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(style.tableSpacing))
} }
@Composable @Composable
private fun TableRows( private fun TableRows(
segment: MarkdownSegment.Table, segment: MarkdownSegment.Table,
columnWidths: List<Int>, columnWidths: List<Int>,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
) { ) {
// Header row // Header row
TableRow( TableRow(
cells = segment.headers, cells = segment.headers,
widths = columnWidths, widths = columnWidths,
isHeader = true, isHeader = true,
textColor = textColor, style = style,
fontSize = fontSize,
) )
// Data rows // Data rows
segment.rows.forEachIndexed { idx, row -> segment.rows.forEachIndexed { idx, row ->
@@ -254,8 +252,7 @@ private fun TableRows(
cells = row, cells = row,
widths = columnWidths, widths = columnWidths,
isHeader = false, isHeader = false,
textColor = textColor, style = style,
fontSize = fontSize,
rowIndex = idx, rowIndex = idx,
) )
} }
@@ -266,14 +263,13 @@ private fun TableRow(
cells: List<String>, cells: List<String>,
widths: List<Int>, widths: List<Int>,
isHeader: Boolean, isHeader: Boolean,
textColor: Color, style: MarkdownStyle,
fontSize: TextUnit,
rowIndex: Int = 0, rowIndex: Int = 0,
) { ) {
val bgColor = when { val bgColor = when {
isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) isHeader -> style.tableHeaderBackground
rowIndex % 2 == 0 -> Color.Transparent rowIndex % 2 == 0 -> Color.Transparent
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f) else -> style.tableStripeBackground
} }
Row( Row(
modifier = Modifier modifier = Modifier
@@ -283,13 +279,19 @@ private fun TableRow(
cells.forEachIndexed { i, cell -> cells.forEachIndexed { i, cell ->
Box( Box(
modifier = Modifier modifier = Modifier
.width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp)) .width(
.padding(horizontal = 8.dp, vertical = 6.dp), (TableCharWidth * widths.getOrElse(i) { 10 })
.coerceAtLeast(TableMinColumnWidth)
)
.padding(
horizontal = style.tableCellPaddingHorizontal,
vertical = style.tableCellPaddingVertical,
),
) { ) {
Text( Text(
text = cell, text = cell,
color = textColor, color = style.textColor,
fontSize = fontSize, fontSize = style.tableFontSize,
fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
softWrap = true, softWrap = true,
) )
@@ -306,6 +308,7 @@ private fun TableRow(
private fun clickableMarkdownText( private fun clickableMarkdownText(
annotatedString: AnnotatedString, annotatedString: AnnotatedString,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
lineHeight: TextUnit = TextUnit.Unspecified,
onUrlClick: (String) -> Unit = {}, onUrlClick: (String) -> Unit = {},
) { ) {
val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length) val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length)
@@ -317,6 +320,7 @@ private fun clickableMarkdownText(
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) } var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text( Text(
text = annotatedString, text = annotatedString,
lineHeight = lineHeight,
modifier = modifier.pointerInput(annotatedString) { modifier = modifier.pointerInput(annotatedString) {
detectTapGestures { offset -> detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures val layout = textLayoutResult ?: return@detectTapGestures
@@ -334,7 +338,7 @@ private fun clickableMarkdownText(
onTextLayout = { textLayoutResult = it }, onTextLayout = { textLayoutResult = it },
) )
} else { } else {
Text(text = annotatedString, modifier = modifier) Text(text = annotatedString, lineHeight = lineHeight, modifier = modifier)
} }
} }
@@ -342,16 +346,27 @@ private fun clickableMarkdownText(
// Convert MarkdownSegment.Text to Compose AnnotatedString // Convert MarkdownSegment.Text to Compose AnnotatedString
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/**
* Текст сегмента → [AnnotatedString].
*
* База ([baseStyle], по умолчанию [MarkdownStyle.bodySpan]) накладывается на ВЕСЬ
* текст, а не только на размеченные спаны: парсер заводит [TextStyleSpan] лишь
* для жирного/курсива/кода/ссылок, поэтому «простой» текст без базы оставался
* без цвета и рисовался цветом по умолчанию (чёрным) — это и был баг «текст
* чёрный вместо белого».
*/
fun MarkdownSegment.Text.toAnnotatedString( fun MarkdownSegment.Text.toAnnotatedString(
baseStyle: SpanStyle = SpanStyle(), style: MarkdownStyle,
colors: AgentikColors = darkAgentikColors, baseStyle: SpanStyle = style.bodySpan(),
): AnnotatedString { ): AnnotatedString {
val builder = AnnotatedString.Builder(text) val builder = AnnotatedString.Builder(text)
if (text.isNotEmpty()) {
builder.addStyle(baseStyle, 0, text.length)
}
for (span in spans) { for (span in spans) {
val style = buildSpanStyle(span, colors) val spanStyle = buildSpanStyle(span, style)
val merged = baseStyle.merge(style) builder.addStyle(baseStyle.merge(spanStyle), span.start, span.end)
builder.addStyle(merged, span.start, span.end)
val link = span.link val link = span.link
if (link != null) { if (link != null) {
builder.addStringAnnotation("URL", link, span.start, span.end) builder.addStringAnnotation("URL", link, span.start, span.end)
@@ -360,23 +375,23 @@ fun MarkdownSegment.Text.toAnnotatedString(
return builder.toAnnotatedString() return builder.toAnnotatedString()
} }
private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle { private fun buildSpanStyle(span: TextStyleSpan, style: MarkdownStyle): SpanStyle {
var style = SpanStyle() var out = SpanStyle()
if (span.bold) style = style.copy(fontWeight = FontWeight.Bold) if (span.bold) out = out.copy(fontWeight = FontWeight.Bold)
if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic) if (span.italic) out = out.copy(fontStyle = FontStyle.Italic)
if (span.code) { if (span.code) {
style = style.copy( out = out.copy(
fontFamily = FontFamily.Monospace, fontFamily = style.monospaceFamily,
fontSize = 13.sp, fontSize = style.inlineCodeFontSize,
background = colors.panelElevated, background = style.inlineCodeBackground,
) )
} }
if (span.strikethrough) style = style.copy(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough) if (span.strikethrough) out = out.copy(textDecoration = TextDecoration.LineThrough)
if (span.link != null) { if (span.link != null) {
style = style.copy( out = out.copy(
color = colors.accent, color = style.linkColor,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = TextDecoration.Underline,
) )
} }
return style return out
} }
@@ -205,15 +205,10 @@ private fun TextContent(content: List<UiContent>, textColor: Color, fontSize: an
@Composable @Composable
private fun MarkdownContent(content: List<UiContent>, agentColor: Color) { private fun MarkdownContent(content: List<UiContent>, agentColor: Color) {
val colors = AgentikTheme.colors
Column { Column {
content.forEach { part -> content.forEach { part ->
when (part) { when (part) {
is UiContent.Text -> renderMarkdownSegments( is UiContent.Text -> renderMarkdownSegments(content = part.body)
content = part.body,
textColor = colors.text,
fontSize = 13.5.sp,
)
is UiContent.Image -> { is UiContent.Image -> {
ChatImage(src = dataUrl(part), alt = null) ChatImage(src = dataUrl(part), alt = null)
@@ -3,6 +3,7 @@ package pw.binom.agentik.desktop.ui.theme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@@ -10,6 +11,9 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import pw.binom.agentik.desktop.settings.ThemeId import pw.binom.agentik.desktop.settings.ThemeId
import pw.binom.agentik.desktop.ui.AgentikMarkdownStyle
import pw.binom.agentik.desktop.ui.LocalMarkdownStyle
import pw.binom.agentik.desktop.ui.MarkdownStyle
/** /**
* Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне * Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне
@@ -146,30 +150,83 @@ val lightAgentikColors = AgentikColors(
codeText = Color(0xFF1A1D23), codeText = Color(0xFF1A1D23),
) )
private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors } /**
* Контракт темы приложения.
*
* Все параметры, которые в UI зависят от выбранной темы, живут здесь:
* - [colors] — семантические цвета (`AgentikColors`);
* - [markdown] — стиль отрисовки md (`MarkdownStyle`, см. `MarkdownStyle.kt`);
* - [dark] — для построения Material-схемы (`toMaterialScheme(dark)`).
*
* Чтобы добавить новую тему, достаточно положить ещё один `object` сюда и
* поднять его в настройках через [ThemeId] + [ThemeId.toTheme]. Экраны и
* рендер md не меняются — они читают только [AgentikTheme.current].
*/
interface AgentikTheme {
val colors: AgentikColors
val markdown: MarkdownStyle
val dark: Boolean
/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */ companion object {
object AgentikTheme { /** Активная тема (то, что положили в [ProvideAgentikTheme]). */
val current: AgentikTheme
@Composable
@ReadOnlyComposable
get() = LocalAgentikTheme.current
/** Короткий доступ к [AgentikTheme.colors] — для кода вида `val colors = AgentikTheme.colors`. */
val colors: AgentikColors val colors: AgentikColors
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = LocalAgentikColors.current get() = current.colors
/** Короткий доступ к [AgentikTheme.markdown] — для `renderMarkdownSegments` и т. п. */
val markdown: MarkdownStyle
@Composable
@ReadOnlyComposable
get() = current.markdown
}
} }
/** /**
* Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение * Локаль, через которую [AgentikTheme.current] доходит до экранов.
* из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`). * Объявлен на уровне файла, чтобы [ProvideAgentikTheme] мог им пользоваться
* напрямую (внутри companion-объекта интерфейса он был бы недоступен снаружи).
*/
private val LocalAgentikTheme = staticCompositionLocalOf<AgentikTheme> { DarkAgentikTheme }
/** Тёмная тема — значения `darkAgentikColors` + её markdown-стиль. */
object DarkAgentikTheme : AgentikTheme {
override val colors: AgentikColors = darkAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
override val dark: Boolean = true
}
/** Светлая тема — значения `lightAgentikColors` + её markdown-стиль. */
object LightAgentikTheme : AgentikTheme {
override val colors: AgentikColors = lightAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(lightAgentikColors)
override val dark: Boolean = false
}
/** Преобразует значение [ThemeId] из настроек (`AgentikTheme.colors.themeId` хранится как `String`) в активный [AgentikTheme]. */
fun String.toAgentikTheme(): AgentikTheme = if (this == ThemeId.LIGHT) LightAgentikTheme else DarkAgentikTheme
/**
* Корневой провайдер темы. Оборачивает всё приложение; принимает готовый
* экземпляр [AgentikTheme] (обычно результат [ThemeId.toTheme]).
*/ */
@Composable @Composable
fun AgentikTheme( fun ProvideAgentikTheme(theme: AgentikTheme, content: @Composable () -> Unit) {
themeId: String, CompositionLocalProvider(
content: @Composable () -> Unit, LocalAgentikTheme provides theme,
// MaterialTheme сам LocalContentColor не подкладывает: без этого любой
// Text без явного color рисовался бы чёрным (дефолт локала), а не текстом темы.
LocalContentColor provides theme.colors.text,
LocalMarkdownStyle provides theme.markdown,
) { ) {
val dark = themeId != ThemeId.LIGHT
val colors = if (dark) darkAgentikColors else lightAgentikColors
CompositionLocalProvider(LocalAgentikColors provides colors) {
MaterialTheme( MaterialTheme(
colorScheme = colors.toMaterialScheme(dark = dark), colorScheme = theme.colors.toMaterialScheme(dark = theme.dark),
content = content, content = content,
) )
} }
@@ -13,11 +13,12 @@ import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.theme.AgentikColors import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -80,14 +81,10 @@ class ThemeRenderTest {
@Test @Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest { fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent { setContent {
AgentikTheme("light") { ProvideAgentikTheme("light".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column { Column {
renderMarkdownSegments( renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
} }
} }
} }
@@ -116,14 +113,10 @@ class ThemeRenderTest {
@Test @Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest { fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent { setContent {
AgentikTheme("dark") { ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column { Column {
renderMarkdownSegments( renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
} }
} }
} }
@@ -6,9 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.renderMarkdownSegments import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -38,12 +39,10 @@ class RealSpeechIT {
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения). // 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest { runComposeUiTest {
setContent { setContent {
AgentikTheme("dark") { ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) { Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments( renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано", content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
) )
} }
} }