flake
This commit is contained in:
+407
-8
@@ -6,18 +6,67 @@ use libghostty_vt::{
|
||||
RenderState, Terminal,
|
||||
alloc::{Allocator, Bytes},
|
||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||
render::{CellIterator, CursorVisualStyle, Dirty, RowIterator},
|
||||
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
||||
style::{RgbColor, Underline},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io::Cursor,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub fn color(c: RgbColor) -> Color32 {
|
||||
Color32::from_rgb(c.r, c.g, c.b)
|
||||
}
|
||||
|
||||
pub fn cell_size(painter: &Painter, font: &FontId) -> egui::Vec2 {
|
||||
painter.ctx().fonts_mut(|fonts| {
|
||||
vec2(
|
||||
fonts.glyph_width(font, 'M').ceil().max(1.0),
|
||||
fonts.row_height(font).ceil().max(1.0),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn powerline_shape(text: &str, rect: Rect, color: Color32) -> Option<egui::Shape> {
|
||||
let mut chars = text.chars();
|
||||
let code = chars.next()? as u32;
|
||||
if !(0xe0b0..=0xe0b7).contains(&code) || chars.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let left = matches!(code, 0xe0b2 | 0xe0b3 | 0xe0b6 | 0xe0b7);
|
||||
let filled = code.is_multiple_of(2);
|
||||
let mut points = if code < 0xe0b4 {
|
||||
vec![
|
||||
rect.left_top(),
|
||||
pos2(rect.right(), rect.center().y),
|
||||
rect.left_bottom(),
|
||||
]
|
||||
} else {
|
||||
(0..=24)
|
||||
.map(|step| {
|
||||
let angle = std::f32::consts::PI * (step as f32 / 24.0 - 0.5);
|
||||
pos2(
|
||||
rect.left() + rect.width() * angle.cos().max(0.0),
|
||||
rect.center().y + rect.height() * 0.5 * angle.sin(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
points[0] = rect.left_top();
|
||||
*points.last_mut().unwrap() = rect.left_bottom();
|
||||
if left {
|
||||
for point in &mut points {
|
||||
point.x = rect.right() - (point.x - rect.left());
|
||||
}
|
||||
}
|
||||
Some(if filled {
|
||||
egui::Shape::convex_polygon(points, color, Stroke::NONE)
|
||||
} else {
|
||||
egui::Shape::line(points, Stroke::new(1.0, color))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct PngDecoder;
|
||||
impl DecodePng for PngDecoder {
|
||||
fn decode_png<'a>(
|
||||
@@ -71,12 +120,35 @@ impl DecodePng for PngDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CachedRow {
|
||||
backgrounds: Vec<egui::Shape>,
|
||||
foregrounds: Vec<egui::Shape>,
|
||||
selection: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple(text, font.clone(), Color32::PLACEHOLDER, f32::INFINITY);
|
||||
job.sections[0].format.line_height = Some(height);
|
||||
job.sections[0].format.valign = egui::Align::Center;
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
pub struct Renderer {
|
||||
state: RenderState<'static>,
|
||||
rows: RowIterator<'static>,
|
||||
cells: CellIterator<'static>,
|
||||
placements: PlacementIterator<'static>,
|
||||
textures: HashMap<u32, (u64, TextureHandle)>,
|
||||
row_cache: Vec<CachedRow>,
|
||||
glyphs: HashMap<String, Arc<egui::Galley>>,
|
||||
font_marker: Option<Arc<egui::Galley>>,
|
||||
cached_colors: Option<Colors>,
|
||||
cached_cell: egui::Vec2,
|
||||
cached_cols: u16,
|
||||
#[cfg(test)]
|
||||
rebuilt_rows: usize,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -87,6 +159,14 @@ impl Renderer {
|
||||
cells: CellIterator::new()?,
|
||||
placements: PlacementIterator::new()?,
|
||||
textures: HashMap::new(),
|
||||
row_cache: Vec::new(),
|
||||
glyphs: HashMap::new(),
|
||||
font_marker: None,
|
||||
cached_colors: None,
|
||||
cached_cell: egui::Vec2::ZERO,
|
||||
cached_cols: 0,
|
||||
#[cfg(test)]
|
||||
rebuilt_rows: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,19 +180,57 @@ impl Renderer {
|
||||
active: bool,
|
||||
) -> Result<()> {
|
||||
let painter = painter.with_clip_rect(rect.intersect(painter.clip_rect()));
|
||||
// Keep one egui galley alive in its cache each frame. A new identity means
|
||||
// fonts/DPI/the atlas were reset, invalidating every cached glyph's UVs.
|
||||
let marker = layout_glyph(&painter, "M".into(), font, cell.y);
|
||||
if self
|
||||
.font_marker
|
||||
.as_ref()
|
||||
.is_none_or(|old| !Arc::ptr_eq(old, &marker))
|
||||
|| self.cached_cell != cell
|
||||
|| self.glyphs.len() > 4096
|
||||
{
|
||||
self.row_cache.clear();
|
||||
self.glyphs.clear();
|
||||
}
|
||||
self.font_marker = Some(marker);
|
||||
self.cached_cell = cell;
|
||||
let snapshot = self.state.update(term)?;
|
||||
let colors = snapshot.colors()?;
|
||||
let cols = snapshot.cols()?;
|
||||
let row_count = snapshot.rows()? as usize;
|
||||
let rebuild_all = snapshot.dirty()? == Dirty::Full
|
||||
|| self.cached_colors.as_ref() != Some(&colors)
|
||||
|| self.cached_cols != cols
|
||||
|| self.row_cache.len() != row_count;
|
||||
self.row_cache.resize_with(row_count, CachedRow::default);
|
||||
self.cached_colors = Some(colors.clone());
|
||||
self.cached_cols = cols;
|
||||
painter.rect_filled(rect, 0, color(colors.background));
|
||||
let mut backgrounds = vec![];
|
||||
let mut foregrounds = vec![];
|
||||
let mut rows = self.rows.update(&snapshot)?;
|
||||
let mut text = String::new();
|
||||
let mut y = 0;
|
||||
while let Some(row) = rows.next() {
|
||||
// Selection can change independently of a row's text dirty flag.
|
||||
let selection = row.selection()?.map(|s| (s.start_x, s.end_x));
|
||||
if !rebuild_all && !row.dirty()? && self.row_cache[y].selection == selection {
|
||||
y += 1;
|
||||
continue;
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.rebuilt_rows += 1;
|
||||
}
|
||||
let mut cached = CachedRow {
|
||||
selection,
|
||||
..Default::default()
|
||||
};
|
||||
let backgrounds = &mut cached.backgrounds;
|
||||
let foregrounds = &mut cached.foregrounds;
|
||||
let mut cells = self.cells.update(row)?;
|
||||
let mut x = 0;
|
||||
while let Some(c) = cells.next() {
|
||||
let pos = rect.min + vec2(x as f32 * cell.x, y as f32 * cell.y);
|
||||
let pos = pos2(x as f32 * cell.x, y as f32 * cell.y);
|
||||
let cell_rect = Rect::from_min_size(pos, cell);
|
||||
let style = c.style()?;
|
||||
let mut fg = color(c.fg_color()?.unwrap_or(colors.foreground));
|
||||
@@ -131,8 +249,16 @@ impl Renderer {
|
||||
if !style.invisible && c.graphemes_len()? > 0 {
|
||||
c.graphemes_utf8(&mut text)?;
|
||||
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
||||
if !text.starts_with('\u{10eeee}') {
|
||||
let galley = painter.layout_no_wrap(text.clone(), font.clone(), fg);
|
||||
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
||||
foregrounds.push(separator);
|
||||
} else if text != " " && !text.starts_with('\u{10eeee}') {
|
||||
let galley = if let Some(galley) = self.glyphs.get(&text) {
|
||||
galley.clone()
|
||||
} else {
|
||||
let galley = layout_glyph(&painter, text.clone(), font, cell.y);
|
||||
self.glyphs.insert(text.clone(), galley.clone());
|
||||
galley
|
||||
};
|
||||
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
|
||||
if style.bold {
|
||||
foregrounds.push(egui::Shape::galley(
|
||||
@@ -157,6 +283,7 @@ impl Renderer {
|
||||
}
|
||||
x += 1;
|
||||
}
|
||||
self.row_cache[y] = cached;
|
||||
row.set_dirty(false)?;
|
||||
y += 1;
|
||||
}
|
||||
@@ -164,10 +291,28 @@ impl Renderer {
|
||||
let mut seen = HashSet::new();
|
||||
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
|
||||
if layer == Layer::BelowText {
|
||||
painter.extend(std::mem::take(&mut backgrounds));
|
||||
painter.extend(
|
||||
self.row_cache
|
||||
.iter()
|
||||
.flat_map(|row| row.backgrounds.iter())
|
||||
.cloned()
|
||||
.map(|mut shape| {
|
||||
shape.translate(rect.min.to_vec2());
|
||||
shape
|
||||
}),
|
||||
);
|
||||
}
|
||||
if layer == Layer::AboveText {
|
||||
painter.extend(std::mem::take(&mut foregrounds));
|
||||
painter.extend(
|
||||
self.row_cache
|
||||
.iter()
|
||||
.flat_map(|row| row.foregrounds.iter())
|
||||
.cloned()
|
||||
.map(|mut shape| {
|
||||
shape.translate(rect.min.to_vec2());
|
||||
shape
|
||||
}),
|
||||
);
|
||||
}
|
||||
paint_images(
|
||||
term,
|
||||
@@ -301,3 +446,257 @@ fn paint_images(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(
|
||||
ctx: &egui::Context,
|
||||
renderer: &mut Renderer,
|
||||
term: &Terminal<'static, '_>,
|
||||
rect: Rect,
|
||||
font: &FontId,
|
||||
) -> Vec<egui::epaint::ClippedShape> {
|
||||
ctx.run(egui::RawInput::default(), |ctx| {
|
||||
let painter = ctx.layer_painter(egui::LayerId::background());
|
||||
renderer
|
||||
.paint(term, &painter, rect, cell_size(&painter, font), font, true)
|
||||
.unwrap();
|
||||
})
|
||||
.shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_rows_match_fresh_rendering_after_terminal_changes() {
|
||||
use libghostty_vt::{
|
||||
selection::Selection,
|
||||
terminal::{Point, PointCoordinate},
|
||||
};
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut term = crate::pane::terminal(&crate::config::Config::default()).unwrap();
|
||||
term.resize(20, 5, 8, 16).unwrap();
|
||||
term.vt_write("\x1b[31mNOR\u{e0b4}\x1b[0m Android\r\nsecond line".as_bytes());
|
||||
let rect = Rect::from_min_size(pos2(12.0, 24.0), vec2(200.0, 120.0));
|
||||
let font = FontId::monospace(16.0);
|
||||
let mut renderer = Renderer::new().unwrap();
|
||||
let initial = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_eq!(renderer.rebuilt_rows, 5);
|
||||
assert_eq!(initial, frame(&ctx, &mut renderer, &term, rect, &font));
|
||||
assert_eq!(renderer.rebuilt_rows, 5, "idle rows should be reused");
|
||||
|
||||
term.vt_write(b"!");
|
||||
frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_eq!(
|
||||
renderer.rebuilt_rows, 6,
|
||||
"only the edited row should rebuild"
|
||||
);
|
||||
let moved = rect.translate(vec2(31.0, 17.0));
|
||||
let actual = frame(&ctx, &mut renderer, &term, moved, &font);
|
||||
assert_eq!(renderer.rebuilt_rows, 6, "moving a pane should reuse rows");
|
||||
assert_eq!(
|
||||
actual,
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, moved, &font)
|
||||
);
|
||||
|
||||
let a = term
|
||||
.grid_ref(Point::Viewport(PointCoordinate { x: 0, y: 0 }))
|
||||
.unwrap();
|
||||
let b = term
|
||||
.grid_ref(Point::Viewport(PointCoordinate { x: 4, y: 1 }))
|
||||
.unwrap();
|
||||
term.set_selection(Some(&Selection::new(a, b, false)))
|
||||
.unwrap();
|
||||
let selected = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_ne!(selected, initial);
|
||||
assert_eq!(
|
||||
selected,
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||
);
|
||||
term.set_selection(None).unwrap();
|
||||
let cleared = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_ne!(cleared, selected);
|
||||
assert_eq!(
|
||||
cleared,
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||
);
|
||||
|
||||
for change in [
|
||||
"\x1b[2;1H\x1b[1;4;7mchanged\x1b[0m",
|
||||
"\r\n1\r\n2\r\n3\r\n4\r\n5\r\n6",
|
||||
"\x1b[?1049hother screen",
|
||||
"\x1b[?1049l",
|
||||
"\x1b]10;#abcdef\x07\x1b]11;#123456\x07",
|
||||
"\x1b[2J",
|
||||
] {
|
||||
term.vt_write(change.as_bytes());
|
||||
assert_eq!(
|
||||
frame(&ctx, &mut renderer, &term, rect, &font),
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font),
|
||||
"cached output differs after {change:?}"
|
||||
);
|
||||
}
|
||||
term.resize(12, 3, 8, 16).unwrap();
|
||||
assert_eq!(
|
||||
frame(&ctx, &mut renderer, &term, rect, &font),
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||
);
|
||||
assert_eq!(renderer.row_cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_glyphs_rebuild_after_font_or_scale_changes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut term = crate::pane::terminal(&crate::config::Config::default()).unwrap();
|
||||
term.resize(20, 5, 8, 16).unwrap();
|
||||
term.vt_write(b"font cache");
|
||||
let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(200.0, 120.0));
|
||||
let font = FontId::monospace(16.0);
|
||||
let mut renderer = Renderer::new().unwrap();
|
||||
frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
let old = renderer.font_marker.clone().unwrap();
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let name = fonts.families[&egui::FontFamily::Monospace][0].clone();
|
||||
fonts
|
||||
.font_data
|
||||
.insert("reloaded".into(), fonts.font_data[&name].clone());
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Monospace)
|
||||
.unwrap()
|
||||
.insert(0, "reloaded".into());
|
||||
ctx.set_fonts(fonts);
|
||||
frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_eq!(renderer.rebuilt_rows, 10);
|
||||
assert!(!Arc::ptr_eq(&old, renderer.font_marker.as_ref().unwrap()));
|
||||
|
||||
ctx.set_pixels_per_point(2.0);
|
||||
let scaled = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||
assert_eq!(renderer.rebuilt_rows, 15);
|
||||
assert_eq!(
|
||||
scaled,
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||
);
|
||||
let larger = FontId::monospace(20.0);
|
||||
assert_eq!(
|
||||
frame(&ctx, &mut renderer, &term, rect, &larger),
|
||||
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &larger)
|
||||
);
|
||||
assert_eq!(renderer.rebuilt_rows, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual CPU rendering benchmark"]
|
||||
fn renderer_frame_cost() {
|
||||
let config = crate::config::Config {
|
||||
font_path: std::env::var_os("RUST_TERM_TEST_FONT").map(Into::into),
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::app::fonts(&config).unwrap());
|
||||
let mut term = crate::pane::terminal(&config).unwrap();
|
||||
term.resize(160, 50, 9, 18).unwrap();
|
||||
let screen = (1..=50)
|
||||
.map(|row| {
|
||||
format!(
|
||||
"\x1b[{row};1H\x1b[{}m{}",
|
||||
31 + row % 7,
|
||||
"NOR Android 0123456789 ".repeat(7)
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
term.vt_write(screen.as_bytes());
|
||||
let mut renderer = Renderer::new().unwrap();
|
||||
for mode in ["idle", "typing", "full"] {
|
||||
let start = std::time::Instant::now();
|
||||
for frame in 0..100 {
|
||||
match mode {
|
||||
"typing" => term.vt_write(format!("\x1b[25;1H{frame:03}").as_bytes()),
|
||||
"full" => term.vt_write(screen.as_bytes()),
|
||||
_ => {}
|
||||
}
|
||||
let output = ctx.run(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
vec2(1600.0, 1000.0),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
|ctx| {
|
||||
renderer
|
||||
.paint(
|
||||
&term,
|
||||
&ctx.layer_painter(egui::LayerId::background()),
|
||||
Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0)),
|
||||
vec2(9.0, 18.0),
|
||||
&FontId::monospace(16.0),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
);
|
||||
std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point));
|
||||
}
|
||||
eprintln!(
|
||||
"{mode}: {:.2} ms/frame",
|
||||
start.elapsed().as_secs_f64() * 10.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powerline_separators_fill_the_cell_height_without_font_padding() {
|
||||
let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(9.0, 18.0));
|
||||
for code in 0xe0b0..=0xe0b7 {
|
||||
let text = char::from_u32(code).unwrap().to_string();
|
||||
let Some(egui::Shape::Path(path)) = powerline_shape(&text, rect, Color32::WHITE) else {
|
||||
panic!("missing separator");
|
||||
};
|
||||
assert_eq!(path.points.first().unwrap().y, rect.top());
|
||||
assert_eq!(path.points.last().unwrap().y, rect.bottom());
|
||||
assert!(path.points.iter().all(|point| rect.contains(*point)));
|
||||
let edge = if matches!(code, 0xe0b2 | 0xe0b3 | 0xe0b6 | 0xe0b7) {
|
||||
rect.right()
|
||||
} else {
|
||||
rect.left()
|
||||
};
|
||||
assert_eq!(path.points.first().unwrap().x, edge);
|
||||
assert_eq!(path.points.last().unwrap().x, edge);
|
||||
}
|
||||
assert!(powerline_shape("M", rect, Color32::WHITE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_cells_match_font_line_metrics() {
|
||||
let config = crate::config::Config {
|
||||
font_path: std::env::var_os("RUST_TERM_TEST_FONT").map(Into::into),
|
||||
..Default::default()
|
||||
};
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_pixels_per_point(scale);
|
||||
ctx.set_fonts(crate::app::fonts(&config).unwrap());
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
let painter = ctx.layer_painter(egui::LayerId::background());
|
||||
for size in [12.0, 16.0, 20.0, 24.0] {
|
||||
let font = FontId::monospace(size);
|
||||
let cell = cell_size(&painter, &font);
|
||||
let line_height = ctx.fonts_mut(|fonts| fonts.row_height(&font));
|
||||
assert_eq!(
|
||||
cell.y,
|
||||
line_height.ceil(),
|
||||
"font size {size}, scale {scale}"
|
||||
);
|
||||
let text = painter.layout_no_wrap("NOR Android".into(), font, Color32::WHITE);
|
||||
assert!(
|
||||
(cell.y - text.size().y).abs() <= 1.0,
|
||||
"cell {cell:?} vs text {:?}",
|
||||
text.size()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user