Перенос темы с 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.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 не подкладывает: без этого любой
|
||||||
val dark = themeId != ThemeId.LIGHT
|
// Text без явного color рисовался бы чёрным (дефолт локала), а не текстом темы.
|
||||||
val colors = if (dark) darkAgentikColors else lightAgentikColors
|
LocalContentColor provides theme.colors.text,
|
||||||
CompositionLocalProvider(LocalAgentikColors provides colors) {
|
LocalMarkdownStyle provides theme.markdown,
|
||||||
|
) {
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user