Add ANSI color support fallback modes

This commit is contained in:
Nanaloveyuki
2026-05-10 15:32:48 +08:00
parent 2d2388c79f
commit 4b54005401
8 changed files with 234 additions and 29 deletions
+21 -3
View File
@@ -74,9 +74,10 @@ test "logger config parser reads formatter and queue options" {
test "logger config parser reads formatter style tags" {
let config = parse_logger_config_text(
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true},\"panel\":{\"bg\":\"#202020\",\"underline\":true}}}}}",
"{\"sink\":{\"kind\":\"text_console\",\"text_formatter\":{\"color_mode\":\"always\",\"color_support\":\"basic\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"fg\":\"#4cc9f0\",\"bold\":true},\"panel\":{\"bg\":\"#202020\",\"underline\":true}}}}}",
)
inspect(style_markup_mode_label(config.sink.text_formatter.style_markup), content="builtin")
inspect(color_support_label(config.sink.text_formatter.color_support), content="basic")
inspect(style_markup_mode_label(config.sink.text_formatter.target_style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.fields_style_markup), content="disabled")
inspect(config.sink.text_formatter.style_tags.length(), content="2")
@@ -151,6 +152,7 @@ test "logger config stringify roundtrips formatter style tags" {
kind=SinkKind::TextConsole,
text_formatter=TextFormatterConfig::new(
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
style_markup=StyleMarkupMode::Builtin,
target_style_markup=StyleMarkupMode::Builtin,
fields_style_markup=StyleMarkupMode::Disabled,
@@ -164,6 +166,7 @@ test "logger config stringify roundtrips formatter style tags" {
)
let config = parse_logger_config_text(text)
inspect(color_mode_label(config.sink.text_formatter.color_mode), content="always")
inspect(color_support_label(config.sink.text_formatter.color_support), content="basic")
inspect(style_markup_mode_label(config.sink.text_formatter.style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.target_style_markup), content="builtin")
inspect(style_markup_mode_label(config.sink.text_formatter.fields_style_markup), content="disabled")
@@ -213,6 +216,7 @@ test "config subtype json helpers stringify stable shapes" {
field_separator=",",
template="[{level}] {message} :: {fields}",
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
style_markup=StyleMarkupMode::Builtin,
target_style_markup=StyleMarkupMode::Builtin,
fields_style_markup=StyleMarkupMode::Disabled,
@@ -221,7 +225,7 @@ test "config subtype json helpers stringify stable shapes" {
},
),
),
content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"bold\":true,\"dim\":false,\"italic\":false,\"underline\":false,\"fg\":\"#4cc9f0\"}}}",
content="{\"show_timestamp\":false,\"show_level\":true,\"show_target\":false,\"show_fields\":true,\"separator\":\" | \",\"field_separator\":\",\",\"template\":\"[{level}] {message} :: {fields}\",\"color_mode\":\"always\",\"color_support\":\"basic\",\"style_markup\":\"builtin\",\"target_style_markup\":\"builtin\",\"fields_style_markup\":\"disabled\",\"style_tags\":{\"accent\":{\"bold\":true,\"dim\":false,\"italic\":false,\"underline\":false,\"fg\":\"#4cc9f0\"}}}",
)
inspect(
stringify_sink_config(
@@ -234,10 +238,24 @@ test "config subtype json helpers stringify stable shapes" {
text_formatter=TextFormatterConfig::new(show_timestamp=false, color_mode=ColorMode::Auto),
),
),
content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\",\"color_mode\":\"auto\",\"style_markup\":\"full\",\"target_style_markup\":\"disabled\",\"fields_style_markup\":\"disabled\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}",
content="{\"kind\":\"file\",\"path\":\"demo.log\",\"append\":false,\"auto_flush\":false,\"text_formatter\":{\"show_timestamp\":false,\"show_level\":true,\"show_target\":true,\"show_fields\":true,\"separator\":\" \",\"field_separator\":\" \",\"template\":\"\",\"color_mode\":\"auto\",\"color_support\":\"truecolor\",\"style_markup\":\"full\",\"target_style_markup\":\"disabled\",\"fields_style_markup\":\"disabled\"},\"rotation\":{\"max_bytes\":128,\"max_backups\":2}}",
)
}
test "config basic color support downgrades hex colors" {
let formatter = TextFormatterConfig::new(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
)
let rendered = format_text(
Record::new(Level::Info, "<#ff0000>hot</> <bg:#010203>bg</>"),
formatter=formatter.to_formatter(),
)
inspect(rendered, content="\u{001b}[31mhot\u{001b}[0m \u{001b}[100mbg\u{001b}[0m")
}
test "config formatter style tags render in built logger" {
let formatter = TextFormatterConfig::new(
show_timestamp=false,
+16
View File
@@ -139,6 +139,22 @@ test "text formatter supports hex inline colors" {
)
}
test "text formatter can downgrade hex colors to basic ansi" {
let rec = record(Level::Info, "<#ff0000>hot</> <bg:#010203>bg</>")
inspect(
format_text(
rec,
formatter=text_formatter(
show_level=false,
show_target=false,
color_mode=ColorMode::Always,
color_support=ColorSupport::Basic,
),
),
content="\u{001b}[31mhot\u{001b}[0m \u{001b}[100mbg\u{001b}[0m",
)
}
test "text formatter keeps unknown inline tags as plain text" {
let rec = record(Level::Info, "<unknown>boom</>")
inspect(
+1
View File
@@ -251,6 +251,7 @@ test {
- supported tokens / 支持的 token: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, `{fields}`
- `color_mode` / `color_mode`: `never`, `auto`, `always`
- `color_support` / `color_support`: `basic`, `truecolor`
- `style_markup` / `style_markup`: `disabled`, `builtin`, `full`
- `target_style_markup` / `target_style_markup`, `fields_style_markup` / `fields_style_markup`: `disabled`, `builtin`, `full`
- inline style tags / inline 样式标签: `<red>...</>`, `<b>...</>`, `<#ff0000>...</>`, `<bg:#202020>...</>`
+14
View File
@@ -18,6 +18,7 @@ pub struct TextFormatterConfig {
field_separator : String
template : String
color_mode : ColorMode
color_support : ColorSupport
style_markup : StyleMarkupMode
target_style_markup : StyleMarkupMode
fields_style_markup : StyleMarkupMode
@@ -33,6 +34,7 @@ pub fn TextFormatterConfig::new(
field_separator~ : String = " ",
template~ : String = "",
color_mode~ : ColorMode = ColorMode::Never,
color_support~ : ColorSupport = ColorSupport::TrueColor,
style_markup~ : StyleMarkupMode = StyleMarkupMode::Full,
target_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
fields_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
@@ -47,6 +49,7 @@ pub fn TextFormatterConfig::new(
field_separator,
template,
color_mode,
color_support,
style_markup,
target_style_markup,
fields_style_markup,
@@ -72,6 +75,7 @@ pub fn TextFormatterConfig::to_formatter(self : TextFormatterConfig) -> TextForm
field_separator=self.field_separator,
template=self.template,
color_mode=self.color_mode,
color_support=self.color_support,
style_markup=self.style_markup,
target_style_markup=self.target_style_markup,
fields_style_markup=self.fields_style_markup,
@@ -869,6 +873,14 @@ fn parse_color_mode(name : String) -> ColorMode raise ConfigError {
}
}
fn parse_color_support(name : String) -> ColorSupport raise ConfigError {
match name.to_upper() {
"BASIC" => ColorSupport::Basic
"TRUECOLOR" => ColorSupport::TrueColor
_ => raise ConfigError::InvalidConfig("Unsupported color support: " + name)
}
}
fn parse_style_markup_mode(name : String) -> StyleMarkupMode raise ConfigError {
match name.to_upper() {
"DISABLED" => StyleMarkupMode::Disabled
@@ -898,6 +910,7 @@ fn parse_text_formatter_config(value : @json_parser.JsonValue) -> TextFormatterC
field_separator=get_string(obj, "field_separator", default=" "),
template=get_string(obj, "template", default=""),
color_mode=parse_color_mode(get_string(obj, "color_mode", default="never")),
color_support=parse_color_support(get_string(obj, "color_support", default="truecolor")),
style_markup=parse_style_markup_mode(get_string(obj, "style_markup", default="full")),
target_style_markup=parse_style_markup_mode(get_string(obj, "target_style_markup", default="disabled")),
fields_style_markup=parse_style_markup_mode(get_string(obj, "fields_style_markup", default="disabled")),
@@ -1024,6 +1037,7 @@ pub fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_pars
"field_separator": @json_parser.JsonValue::String(config.field_separator),
"template": @json_parser.JsonValue::String(config.template),
"color_mode": @json_parser.JsonValue::String(color_mode_label(config.color_mode)),
"color_support": @json_parser.JsonValue::String(color_support_label(config.color_support)),
"style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.style_markup)),
"target_style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.target_style_markup)),
"fields_style_markup": @json_parser.JsonValue::String(style_markup_mode_label(config.fields_style_markup)),
+175 -26
View File
@@ -6,6 +6,11 @@ pub(all) enum ColorMode {
Always
}
pub(all) enum ColorSupport {
Basic
TrueColor
}
pub(all) enum StyleMarkupMode {
Disabled
Builtin
@@ -149,6 +154,8 @@ pub fn reset_global_style_tag_registry() -> Unit {
priv struct InlineStyle {
fg_code : String?
bg_code : String?
fg_basic_name : String?
bg_basic_name : String?
bold : Bool
dim : Bool
italic : Bool
@@ -182,6 +189,7 @@ pub struct TextFormatter {
field_separator : String
template : String
color_mode : ColorMode
color_support : ColorSupport
style_markup : StyleMarkupMode
target_style_markup : StyleMarkupMode
fields_style_markup : StyleMarkupMode
@@ -197,6 +205,7 @@ pub fn text_formatter(
field_separator~ : String = " ",
template~ : String = "",
color_mode~ : ColorMode = ColorMode::Never,
color_support~ : ColorSupport = ColorSupport::TrueColor,
style_markup~ : StyleMarkupMode = StyleMarkupMode::Full,
target_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
fields_style_markup~ : StyleMarkupMode = StyleMarkupMode::Disabled,
@@ -211,6 +220,7 @@ pub fn text_formatter(
field_separator,
template,
color_mode,
color_support,
style_markup,
target_style_markup,
fields_style_markup,
@@ -218,6 +228,17 @@ pub fn text_formatter(
}
}
pub fn color_support_label(support : ColorSupport) -> String {
match support {
ColorSupport::Basic => "basic"
ColorSupport::TrueColor => "truecolor"
}
}
pub fn TextFormatter::with_color_support(self : TextFormatter, color_support : ColorSupport) -> TextFormatter {
{ ..self, color_support }
}
pub fn style_markup_mode_label(mode : StyleMarkupMode) -> String {
match mode {
StyleMarkupMode::Disabled => "disabled"
@@ -314,7 +335,16 @@ fn ansi_wrap_with_style(text : String, style : InlineStyle, enabled : Bool) -> S
}
fn default_inline_style() -> InlineStyle {
{ fg_code: None, bg_code: None, bold: false, dim: false, italic: false, underline: false }
{
fg_code: None,
bg_code: None,
fg_basic_name: None,
bg_basic_name: None,
bold: false,
dim: false,
italic: false,
underline: false,
}
}
fn named_color_code(tag : String) -> String? {
@@ -361,6 +391,88 @@ fn named_bg_color_code(tag : String) -> String? {
}
}
fn basic_name_from_hex(value : String) -> String {
let r = hex_pair_to_int(value.unsafe_get(1), value.unsafe_get(2))
let g = hex_pair_to_int(value.unsafe_get(3), value.unsafe_get(4))
let b = hex_pair_to_int(value.unsafe_get(5), value.unsafe_get(6))
let max_value = if r >= g && r >= b { r } else if g >= b { g } else { b }
if max_value < 48 {
"bright_black"
} else {
let min_value = if r <= g && r <= b { r } else if g <= b { g } else { b }
let spread = max_value - min_value
if spread < 24 {
if max_value > 170 {
"white"
} else if max_value > 96 {
"bright_black"
} else {
"black"
}
} else if r >= g && r >= b {
if g > 96 && b < 96 {
"yellow"
} else if b > 96 && g < 96 {
"magenta"
} else {
"red"
}
} else if g >= r && g >= b {
if r > 96 && b < 96 {
"yellow"
} else if b > 96 && r < 96 {
"cyan"
} else {
"green"
}
} else {
if r > 96 && g < 96 {
"magenta"
} else if g > 96 && r < 96 {
"cyan"
} else {
"blue"
}
}
}
}
fn resolve_inline_color_code(
formatter : TextFormatter,
basic_name : String?,
truecolor_code : String,
) -> String {
match formatter.color_support {
ColorSupport::TrueColor => truecolor_code
ColorSupport::Basic => match basic_name {
Some(name) => named_code_from_basic_name(name).unwrap_or(truecolor_code)
None => truecolor_code
}
}
}
fn named_code_from_basic_name(name : String) -> String? {
named_color_code(name)
}
fn named_bg_code_from_basic_name(name : String) -> String? {
named_bg_color_code(name)
}
fn resolve_inline_bg_code(
formatter : TextFormatter,
basic_name : String?,
truecolor_code : String,
) -> String {
match formatter.color_support {
ColorSupport::TrueColor => truecolor_code
ColorSupport::Basic => match basic_name {
Some(name) => named_bg_code_from_basic_name(name).unwrap_or(truecolor_code)
None => truecolor_code
}
}
}
fn is_hex_color(value : String) -> Bool {
if value.length() != 7 {
return false
@@ -416,34 +528,61 @@ fn rgb_bg_code_from_hex(value : String) -> String {
"48;2;\{r};\{g};\{b}"
}
fn inline_style_from_text_style(base : InlineStyle, style : TextStyle) -> InlineStyle? {
let fg_code = match style.fg {
None => Some(base.fg_code)
Some(value) => match named_color_code(normalize_style_tag_name(value)) {
Some(code) => Some(Some(code))
None => if is_hex_color(value) {
Some(Some(rgb_fg_code(value)))
} else {
None
fn inline_style_from_text_style(
base : InlineStyle,
style : TextStyle,
formatter : TextFormatter,
) -> InlineStyle? {
let fg = match style.fg {
None => Some((base.fg_code, base.fg_basic_name))
Some(value) => {
let normalized = normalize_style_tag_name(value)
match named_color_code(normalized) {
Some(code) => Some((Some(code), Some(normalized)))
None => if is_hex_color(value) {
let basic_name = basic_name_from_hex(value)
Some((
Some(resolve_inline_color_code(formatter, Some(basic_name), rgb_fg_code(value))),
Some(basic_name),
))
} else {
None
}
}
}
}
let bg_code = match style.bg {
None => Some(base.bg_code)
Some(value) => match named_bg_color_code(normalize_style_tag_name(value)) {
Some(code) => Some(Some(code))
None => if is_hex_color(value) {
Some(Some(rgb_bg_code_from_hex(value)))
} else {
None
let bg = match style.bg {
None => Some((base.bg_code, base.bg_basic_name))
Some(value) => {
let normalized = normalize_style_tag_name(value)
match named_bg_color_code(normalized) {
Some(code) => {
let bg_name = if normalized.has_prefix("bright_") {
normalized
} else {
normalized
}
Some((Some(code), Some(bg_name)))
}
None => if is_hex_color(value) {
let basic_name = basic_name_from_hex(value)
Some((
Some(resolve_inline_bg_code(formatter, Some(basic_name), rgb_bg_code_from_hex(value))),
Some(basic_name),
))
} else {
None
}
}
}
}
match fg_code {
Some(next_fg) => match bg_code {
Some(next_bg) => Some({
fg_code: next_fg,
bg_code: next_bg,
match fg {
Some((next_fg_code, next_fg_name)) => match bg {
Some((next_bg_code, next_bg_name)) => Some({
fg_code: next_fg_code,
bg_code: next_bg_code,
fg_basic_name: next_fg_name,
bg_basic_name: next_bg_name,
bold: base.bold || style.bold,
dim: base.dim || style.dim,
italic: base.italic || style.italic,
@@ -511,12 +650,22 @@ fn resolve_registered_text_style(tag : String, formatter : TextFormatter) -> Tex
fn apply_inline_tag(style : InlineStyle, tag : String, formatter : TextFormatter) -> InlineStyle? {
let normalized = normalize_style_tag_name(tag)
if is_hex_color(tag) {
Some({ ..style, fg_code: Some(rgb_fg_code(tag)) })
let basic_name = basic_name_from_hex(tag)
Some({
..style,
fg_code: Some(resolve_inline_color_code(formatter, Some(basic_name), rgb_fg_code(tag))),
fg_basic_name: Some(basic_name),
})
} else if normalized.length() == 10 && normalized.has_prefix("bg:") && is_hex_color(normalized[3:].to_owned()) {
Some({ ..style, bg_code: Some(rgb_bg_code(normalized)) })
let basic_name = basic_name_from_hex(normalized[3:].to_owned())
Some({
..style,
bg_code: Some(resolve_inline_bg_code(formatter, Some(basic_name), rgb_bg_code(normalized))),
bg_basic_name: Some(basic_name),
})
} else {
match resolve_registered_text_style(normalized, formatter) {
Some(spec) => inline_style_from_text_style(style, spec)
Some(spec) => inline_style_from_text_style(style, spec, formatter)
None => None
}
}