Добавлены тесты рендера и стилизации Markdown, реализован MarkdownStyle для самостоятельного управления стилями.

This commit is contained in:
2026-09-28 16:29:21 +03:00
parent 9c2e794a73
commit e9f3fe96fe
3 changed files with 461 additions and 0 deletions
@@ -0,0 +1,152 @@
package pw.binom.agentik.desktop.ui
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
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.darkAgentikColors
/**
* Стиль отрисовки markdown: цвета, шрифты и отступы, из которых рендер
* ([renderMarkdownSegments]) берёт решительно всё.
*
* Зачем отдельный контракт: рендер не должен знать ни про тему приложения, ни
* про конкретные числа. Раньше он получал только `textColor` + `fontSize`, а
* остальное (фон код-блока, цвет ссылки, отступы, фон таблицы) тянул из
* `AgentikTheme`/`MaterialTheme` напрямую — из-за чего часть слотов жила вне
* темы, а тело сообщения могло уехать в цвет по умолчанию (чёрный).
*
* Реализация под наш клиент — [AgentikMarkdownStyle]; текущий стиль лежит в
* [LocalMarkdownStyle] (его подкладывает `AgentikTheme`), поэтому вызову
* достаточно `renderMarkdownSegments(content)`.
*/
@Immutable
interface MarkdownStyle {
/** Цвет основного текста сообщения (в тёмной теме — светлый). */
val textColor: Color
/** Кегль тела сообщения. */
val bodyFontSize: TextUnit
val bodyFontFamily: FontFamily
/** Интервал строк тела; [TextUnit.Unspecified] — на усмотрение движка. */
val bodyLineHeight: TextUnit
/** Ссылки: цвет + подчёркивание. */
val linkColor: Color
/** Inline-код: `code`. */
val monospaceFamily: FontFamily
val inlineCodeFontSize: TextUnit
val inlineCodeBackground: Color
/** Блок кода (``` … ```). */
val codeBackground: Color
val codeTextColor: Color
val codeHeaderBackground: Color
val codeFontSize: TextUnit
/** Таблица. */
val tableSurface: Color
val tableHeaderBackground: Color
val tableStripeBackground: Color
val tableFontSize: TextUnit
/** Мелкие подписи (язык код-блока, иконка копирования) и разделитель `---`. */
val labelColor: Color
val labelFontSize: TextUnit
val divider: Color
/** Отступы. */
val paragraphSpacing: Dp
val blockSpacing: Dp
val tableSpacing: Dp
val codePadding: Dp
val codeHeaderPaddingHorizontal: Dp
val codeHeaderPaddingVertical: Dp
val tableCellPaddingHorizontal: Dp
val tableCellPaddingVertical: Dp
val copyIconSize: Dp
/** Ширина контента, когда сообщение лежит в горизонтально скроллящемся пузыре. */
val maxContentWidth: Dp
}
/**
* База, накладываемая на ВЕСЬ текстовый сегмент.
*
* Парсер заводит стилевые спаны только для размеченных участков (жирный/код/
* ссылка), поэтому без этой базы «простой» текст остаётся без цвета и рисуется
* цветом по умолчанию (чёрным) — это и был баг «текст чёрный вместо белого».
*
* Именно функция, а не свойство интерфейса: с делегированием
* (`object : MarkdownStyle by otherStyle`) свойство с реализацией по умолчанию
* уезжает в делегат и перекрытый `textColor` в базе не учитывается.
* [bodyLineHeight] сюда не входит — интервал строк задаётся у абзаца
* (`Text(lineHeight = …)`), в `SpanStyle` такого слота нет.
*/
fun MarkdownStyle.bodySpan(): SpanStyle = SpanStyle(
color = textColor,
fontSize = bodyFontSize,
fontFamily = bodyFontFamily,
)
/**
* Наш стиль: цвета — из [AgentikColors] активной темы, типографика и отступы —
* из макетов клиента (`sketches/index.html`).
*/
@Immutable
class AgentikMarkdownStyle(private val colors: AgentikColors) : MarkdownStyle {
override val textColor: Color get() = colors.text
override val bodyFontSize: TextUnit = 13.5.sp
override val bodyFontFamily: FontFamily = FontFamily.Default
override val bodyLineHeight: TextUnit = 20.sp
override val linkColor: Color get() = colors.accent
override val monospaceFamily: FontFamily = FontFamily.Monospace
override val inlineCodeFontSize: TextUnit = 13.sp
override val inlineCodeBackground: Color get() = colors.panelElevated
override val codeBackground: Color get() = colors.codeBackground
override val codeTextColor: Color get() = colors.codeText
override val codeHeaderBackground: Color get() = colors.panelElevated
override val codeFontSize: TextUnit = 13.sp
override val tableSurface: Color get() = colors.panel.copy(alpha = 0.3f)
override val tableHeaderBackground: Color get() = colors.accent.copy(alpha = 0.1f)
override val tableStripeBackground: Color get() = colors.text.copy(alpha = 0.03f)
override val tableFontSize: TextUnit = 13.5.sp
override val labelColor: Color get() = colors.textDim
override val labelFontSize: TextUnit = 12.sp
override val divider: Color get() = colors.text.copy(alpha = 0.25f)
override val paragraphSpacing: Dp = 12.dp
override val blockSpacing: Dp = 4.dp
override val tableSpacing: Dp = 6.dp
override val codePadding: Dp = 12.dp
override val codeHeaderPaddingHorizontal: Dp = 12.dp
override val codeHeaderPaddingVertical: Dp = 6.dp
override val tableCellPaddingHorizontal: Dp = 8.dp
override val tableCellPaddingVertical: Dp = 6.dp
override val copyIconSize: Dp = 16.dp
override val maxContentWidth: Dp = 600.dp
}
/** Стиль по умолчанию — тёмная тема (эталонная); совпадает с дефолтом [AgentikColors]. */
private val DefaultMarkdownStyle: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
/**
* Текущий стиль markdown. Подкладывается `AgentikTheme`, поэтому экраны читают
* его неявно: `renderMarkdownSegments(content)`.
*/
val LocalMarkdownStyle = staticCompositionLocalOf<MarkdownStyle> { DefaultMarkdownStyle }