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