use eframe::egui::{Color32, FontId, TextFormat, text::LayoutJob};
use crate::theme::Palette;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span<'a> {
pub text: &'a str,
pub color: Option<u8>,
pub bold: bool,
}
pub fn parse(line: &str) -> Vec<Span<'_>> {
let mut spans = Vec::new();
let mut color = None;
let mut bold = false;
let mut rest = line;
while let Some(start) = rest.find('\x1b') {
if start > 0 {
spans.push(Span {
text: &rest[..start],
color,
bold,
});
}
let after = &rest[start + 1..];
let Some(body) = after.strip_prefix('[') else {
rest = after;
continue;
};
let end = body
.char_indices()
.find(|(_, c)| ('@'..='~').contains(c))
.map(|(i, _)| i);
let Some(end) = end else {
rest = "";
break;
};
if body[end..].starts_with('m') {
apply_sgr(&body[..end], &mut color, &mut bold);
}
rest = &body[end + 1..];
}
if !rest.is_empty() {
spans.push(Span {
text: rest,
color,
bold,
});
}
spans
}
fn apply_sgr(params: &str, color: &mut Option<u8>, bold: &mut bool) {
if params.is_empty() {
*color = None;
*bold = false;
return;
}
for code in params.split(';').filter_map(|p| p.parse::<u8>().ok()) {
match code {
0 => {
*color = None;
*bold = false;
}
1 => *bold = true,
22 => *bold = false,
30..=37 => *color = Some(code - 30),
90..=97 => *color = Some(code - 90 + 8),
39 => *color = None,
_ => {}
}
}
}
pub fn strip(line: &str) -> String {
parse(line).into_iter().map(|s| s.text).collect()
}
pub fn ansi_color(p: &Palette, index: u8) -> Color32 {
let dark = p.bg.r() < 128;
match index % 8 {
0 => p.muted,
1 => p.danger,
2 => p.success,
3 => p.warning,
4 => {
if dark {
Color32::from_rgb(0x6c, 0xa8, 0xff)
} else {
Color32::from_rgb(0x1f, 0x5f, 0xd1)
}
}
5 => {
if dark {
Color32::from_rgb(0xd0, 0x8c, 0xff)
} else {
Color32::from_rgb(0x8a, 0x3f, 0xc7)
}
}
6 => {
if dark {
Color32::from_rgb(0x4f, 0xd1, 0xd9)
} else {
Color32::from_rgb(0x0b, 0x7f, 0x8a)
}
}
_ => p.text,
}
}
pub fn layout(p: &Palette, line: &str, highlight: &str, font: FontId) -> LayoutJob {
let spans = parse(line);
let plain: String = spans.iter().map(|s| s.text).collect();
let tint = if spans.iter().all(|s| s.color.is_none()) {
level_color(p, &plain)
} else {
None
};
let lower = plain.to_lowercase();
let matches: Vec<(usize, usize)> = if highlight.is_empty() {
Vec::new()
} else {
lower
.match_indices(highlight)
.map(|(i, m)| (i, i + m.len()))
.collect()
};
let mut job = LayoutJob::default();
let mut offset = 0;
for span in spans {
let color = span
.color
.map(|c| ansi_color(p, c))
.or(tint)
.unwrap_or(p.text);
let mut cuts = vec![0, span.text.len()];
for &(a, b) in &matches {
for edge in [a, b] {
if edge > offset && edge < offset + span.text.len() {
cuts.push(edge - offset);
}
}
}
cuts.sort_unstable();
cuts.dedup();
for pair in cuts.windows(2) {
let (a, b) = (pair[0], pair[1]);
let Some(text) = span.text.get(a..b) else {
continue;
};
let highlighted = matches
.iter()
.any(|&(m0, m1)| offset + a >= m0 && offset + b <= m1);
job.append(
text,
0.0,
TextFormat {
font_id: font.clone(),
color,
background: if highlighted {
p.warning.gamma_multiply(0.35)
} else {
Color32::TRANSPARENT
},
..Default::default()
},
);
}
offset += span.text.len();
}
job
}
fn level_color(p: &Palette, line: &str) -> Option<Color32> {
let upper = line.to_uppercase();
if ["ERROR", "FATAL", "PANIC", "EXCEPTION"]
.iter()
.any(|w| upper.contains(w))
{
Some(p.danger)
} else if upper.contains("WARN") {
Some(p.warning)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_colours_and_resets() {
let spans = parse("\x1b[1;31mERR\x1b[0m done");
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "ERR");
assert_eq!(spans[0].color, Some(1));
assert!(spans[0].bold);
assert_eq!(spans[1].text, " done");
assert_eq!(spans[1].color, None);
}
#[test]
fn bright_colours_and_other_codes() {
let spans = parse("\x1b[92mok\x1b[2K\x1b[39m!");
assert_eq!(spans[0].color, Some(10));
assert_eq!(strip("\x1b[92mok\x1b[2K\x1b[39m!"), "ok!");
assert_eq!(strip("tail\x1b[3"), "tail");
}
#[test]
fn plain_text_is_one_span() {
assert_eq!(
parse("hello"),
vec![Span {
text: "hello",
color: None,
bold: false
}]
);
}
#[test]
fn highlights_matches() {
let p = &crate::theme::LIGHT;
let job = layout(p, "GET /api 200", "api", FontId::monospace(12.0));
let highlighted: Vec<&str> = job
.sections
.iter()
.filter(|s| s.format.background != Color32::TRANSPARENT)
.map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0])
.collect();
assert_eq!(highlighted, vec!["api"]);
}
}