diff --git a/config.example.toml b/config.example.toml index 03eb931..ac7be91 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,7 +2,7 @@ # Omit shell to use $SHELL. Arguments are separate array entries. # shell = ["/bin/bash", "--login"] # working_directory = "/home/you" -font_path = "/usr/local/share/fonts/MononokiNerdFont-Regular.ttf" +font_path = "/usr/local/share/fonts/MononokiNerdFontMono-Regular.ttf" font_size = 16 scrollback = 10000 window_width = 1100 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f95a496 --- /dev/null +++ b/flake.lock @@ -0,0 +1,45 @@ +{ + "nodes": { + "ghostty-src": { + "flake": false, + "locked": { + "lastModified": 1783806457, + "narHash": "sha256-1Zz65SCk3rkJ9+Q0MmyNOTNiDSLBRIHRd3IvFM4iNXw=", + "owner": "ghostty-org", + "repo": "ghostty", + "rev": "a887df42c56f6de86c0fe6da9c4eeca37931e083", + "type": "github" + }, + "original": { + "owner": "ghostty-org", + "repo": "ghostty", + "rev": "a887df42c56f6de86c0fe6da9c4eeca37931e083", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1788746152, + "narHash": "sha256-RaKngusl5I8pNi8RfrVvsHq3NZe7eFWzDlb01+hEVqY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "42f17a57f4f6e33b3de3dca0a2a5ea5233169d02", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "ghostty-src": "ghostty-src", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e8606a9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,171 @@ +{ + description = "Ghostty-powered Rust terminal"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + ghostty-src = { + # Must match libghostty-vt-sys in Cargo.lock. + url = "github:ghostty-org/ghostty/a887df42c56f6de86c0fe6da9c4eeca37931e083"; + flake = false; + }; + }; + + outputs = + { + self, + nixpkgs, + ghostty-src, + }: + let + inherit (nixpkgs) lib; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + forAllSystems = lib.genAttrs systems; + perSystem = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + manifest = builtins.fromTOML (builtins.readFile ./Cargo.toml); + zig = pkgs.zig_0_15; + zigForCargo = pkgs.writeShellScriptBin "zig" '' + if [ "$1" = build ]; then + shift + exec ${lib.getExe zig} build -Dcpu=baseline "-j''${NIX_BUILD_CORES:-1}" "$@" + fi + exec ${lib.getExe zig} "$@" + ''; + ghosttyDeps = pkgs.callPackage "${ghostty-src}/build.zig.zon.nix" { + name = "rust-term-ghostty-zig-deps"; + zig_0_15 = zig; + # Zig 0.15's build runner needs real directories, not a symlink farm. + linkFarm = + name: entries: + pkgs.runCommand name { } '' + mkdir -p "$out" + ${lib.concatMapStringsSep "\n" (entry: '' + cp -rL ${entry.path} "$out"/${lib.escapeShellArg entry.name} + '') entries} + ''; + }; + runtimeLibraries = lib.optionals pkgs.stdenv.hostPlatform.isLinux ( + with pkgs; + [ + libx11 + libxcursor + libxi + libxrandr + libxrender + libxcb + libxkbcommon + wayland + libGL + vulkan-loader + ] + ); + ghosttyEnv = { + GHOSTTY_SOURCE_DIR = "${ghostty-src}"; + GHOSTTY_ZIG_SYSTEM_DIR = "${ghosttyDeps}"; + LIBGHOSTTY_VT_SYS_OPTIMIZE = "ReleaseFast"; + }; + rustTerm = pkgs.rustPlatform.buildRustPackage ( + { + pname = manifest.package.name; + version = manifest.package.version; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./config.example.toml + ./src + ]; + }; + cargoLock.lockFile = ./Cargo.lock; + strictDeps = true; + + nativeBuildInputs = [ + zig + pkgs.pkg-config + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.makeWrapper ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ + pkgs.darwin.cctools + pkgs.xcbuild + ]; + buildInputs = runtimeLibraries; + + # Cargo owns the phases; Zig is invoked by libghostty-vt-sys. + dontUseZigConfigure = true; + dontUseZigBuild = true; + dontUseZigCheck = true; + dontUseZigInstall = true; + + preBuild = '' + export PATH="${zigForCargo}/bin:$PATH" + ''; + + # PTY tests require a host shell and PTY devices outside the sandbox. + checkFlags = [ + "--test-threads=1" + "--skip=app::tests::" + "--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized" + ]; + + postFixup = lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' + wrapProgram "$out/bin/rust-term" \ + --suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibraries} + ''; + + meta = { + description = manifest.package.description; + license = lib.licenses.mit; + mainProgram = "rust-term"; + platforms = systems; + }; + } + // ghosttyEnv + ); + in + { + packages = { + default = rustTerm; + rust-term = rustTerm; + }; + devShells.default = pkgs.mkShell ( + { + inputsFrom = [ rustTerm ]; + packages = [ + pkgs.cargo + pkgs.rustc + pkgs.rustfmt + pkgs.clippy + zig + ]; + shellHook = '' + export ZIG_GLOBAL_CACHE_DIR="$PWD/target/zig-global-cache" + ${lib.optionalString pkgs.stdenv.hostPlatform.isLinux '' + export LD_LIBRARY_PATH="${lib.makeLibraryPath runtimeLibraries}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + ''} + ''; + } + // ghosttyEnv + ); + } + ); + in + { + packages = forAllSystems (system: perSystem.${system}.packages); + devShells = forAllSystems (system: perSystem.${system}.devShells); + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/rust-term"; + meta.description = "Run rust-term"; + }; + }); + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt); + }; +} diff --git a/src/app.rs b/src/app.rs index 7d24407..4fd1b8c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,7 +76,8 @@ impl App { let active = self.tabs[self.current].active; match action { Action::NewPane => { - let action = if self.tabs[self.current].floats_visible { + let tab = &self.tabs[self.current]; + let action = if tab.floats_visible && !tab.floats.is_empty() { Action::NewFloating } else { let rect = self.panes[&active].last_rect; @@ -195,20 +196,43 @@ impl App { } fn close(&mut self, id: PaneId, ctx: &egui::Context) { - self.panes.remove(&id); + let closed_rect = self + .panes + .remove(&id) + .map(|pane| pane.last_rect) + .filter(|rect| rect.is_positive() && rect.is_finite()); for tab in &mut self.tabs { if !tab.ids().contains(&id) { continue; } + let was_floating = tab.floats.contains(&id); tab.detach(id); - if tab.active == id - && let Some(next) = tab.ids().first() - { - tab.active = *next; - } if tab.root.is_none() { tab.floats_visible = true; } + if tab.active == id { + if was_floating && !tab.floats.is_empty() { + let nearest = tab + .floats + .iter() + .map(|candidate| { + let rect = self.panes[candidate].last_rect; + let distance = closed_rect + .filter(|_| rect.is_positive() && rect.is_finite()) + .map_or(f32::INFINITY, |source| { + source.center().distance_sq(rect.center()) + }); + (*candidate, distance) + }) + .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + if let Some((next, _)) = nearest { + tab.active = next; + tab.floats_visible = true; + } + } else if let Some(next) = tab.navigation_ids().first() { + tab.active = *next; + } + } } self.tabs.retain(|t| !t.ids().is_empty()); self.current = self.current.min(self.tabs.len().saturating_sub(1)); @@ -289,6 +313,9 @@ impl App { } fn flush_keys(&mut self, events: &mut Vec) -> Result<()> { + if events.is_empty() { + return Ok(()); + } let active = self.tabs[self.current].active; if !self.tabs[self.current].is_visible(active) { events.clear(); @@ -603,4 +630,81 @@ mod tests { assert_eq!(app.tabs[0].floats, vec![3]); assert!(app.tabs[0].floats_visible); } + + #[test] + fn new_pane_floats_only_when_existing_floats_are_visible() { + let ctx = egui::Context::default(); + let mut app = test_app(&ctx); + app.action(Action::NewPane, &ctx).unwrap(); + assert!(app.tabs[0].floats.is_empty()); + assert_eq!(app.tabs[0].active, 2); + + app.action(Action::ToggleFloats, &ctx).unwrap(); + assert_eq!(app.tabs[0].floats, vec![3]); + app.action(Action::NewPane, &ctx).unwrap(); + assert_eq!(app.tabs[0].floats, vec![3, 4]); + + app.action(Action::ToggleFloats, &ctx).unwrap(); + app.action(Action::NewPane, &ctx).unwrap(); + assert_eq!(app.tabs[0].floats, vec![3, 4]); + assert_eq!(app.tabs[0].active, 5); + assert!(!app.tabs[0].floats_visible); + + app.action(Action::ToggleFloats, &ctx).unwrap(); + app.action(Action::ClosePane, &ctx).unwrap(); + app.action(Action::ClosePane, &ctx).unwrap(); + assert!(app.tabs[0].floats.is_empty()); + assert!(!app.tabs[0].floats_visible); + app.action(Action::NewPane, &ctx).unwrap(); + assert!(app.tabs[0].floats.is_empty()); + assert_eq!(app.tabs[0].active, 6); + + app.action(Action::ToggleFloats, &ctx).unwrap(); + assert_eq!(app.tabs[0].floats, vec![7]); + app.action(Action::ToggleFloating, &ctx).unwrap(); + assert!(!app.tabs[0].floats_visible); + app.action(Action::NewPane, &ctx).unwrap(); + assert!(app.tabs[0].floats.is_empty()); + assert_eq!(app.tabs[0].active, 8); + } + + #[test] + fn closing_float_focuses_another_float_before_tiles() { + let ctx = egui::Context::default(); + let mut app = test_app(&ctx); + app.action(Action::NewFloating, &ctx).unwrap(); + app.action(Action::NewFloating, &ctx).unwrap(); + app.action(Action::ClosePane, &ctx).unwrap(); + assert_eq!(app.tabs[0].active, 2); + app.action(Action::NewFloating, &ctx).unwrap(); + app.close(1, &ctx); + app.action(Action::ToggleFloats, &ctx).unwrap(); + app.action(Action::ClosePane, &ctx).unwrap(); + assert_eq!(app.tabs[0].active, 2); + assert!(app.tabs[0].floats_visible); + } + + #[test] + fn closing_float_selects_nearest_float_not_first_or_nearer_tile() { + let ctx = egui::Context::default(); + let mut app = test_app(&ctx); + for _ in 0..3 { + app.action(Action::NewFloating, &ctx).unwrap(); + } + for (id, x, y) in [ + (1, 200.0, 200.0), + (2, 0.0, 0.0), + (3, 180.0, 180.0), + (4, 200.0, 200.0), + ] { + app.panes.get_mut(&id).unwrap().last_rect = + Rect::from_min_size(egui::pos2(x, y), vec2(100.0, 100.0)); + } + app.action(Action::ClosePane, &ctx).unwrap(); + assert_eq!(app.tabs[0].active, 3); + app.action(Action::ClosePane, &ctx).unwrap(); + assert_eq!(app.tabs[0].active, 2); + app.action(Action::ClosePane, &ctx).unwrap(); + assert_eq!(app.tabs[0].active, 1); + } } diff --git a/src/layout.rs b/src/layout.rs index 81547f1..490c15a 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -177,12 +177,26 @@ impl Tab { pub fn detach(&mut self, id: PaneId) { self.root = self.root.take().and_then(|r| r.remove(id)); self.floats.retain(|p| *p != id); + if self.floats.is_empty() { + self.floats_visible = false; + } self.zoomed = false; } pub fn is_visible(&self, id: PaneId) -> bool { self.floats_visible || !self.floats.contains(&id) } + pub fn navigation_ids(&self) -> Vec { + if self.floats_visible && !self.floats.is_empty() { + self.floats.clone() + } else { + self.ids() + .into_iter() + .filter(|id| !self.floats.contains(id)) + .collect() + } + } + pub fn toggle_floats(&mut self) { self.floats_visible = !self.floats_visible; self.zoomed = false; @@ -213,7 +227,7 @@ impl Tab { } else { 1.0 }; - let ids = self.ids(); + let ids = self.navigation_ids(); let next = rects .iter() .filter_map(|(id, rect)| { @@ -248,16 +262,14 @@ impl Tab { } } pub fn cycle(&mut self, backwards: bool) { - let ids: Vec<_> = self - .ids() - .into_iter() - .filter(|id| self.floats_visible || !self.floats.contains(id)) - .collect(); + let ids = self.navigation_ids(); if ids.is_empty() { return; } - let pos = ids.iter().position(|p| *p == self.active).unwrap_or(0); - self.active = ids[(pos + if backwards { ids.len() - 1 } else { 1 }) % ids.len()]; + self.active = match ids.iter().position(|p| *p == self.active) { + Some(pos) => ids[(pos + if backwards { ids.len() - 1 } else { 1 }) % ids.len()], + None => ids[if backwards { ids.len() - 1 } else { 0 }], + }; } } @@ -362,4 +374,49 @@ mod tests { workspace.area() ); } + + #[test] + fn visible_floats_constrain_directional_and_cyclic_navigation() { + let mut tab = Tab::new(1, 1); + tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal); + tab.floats = vec![3, 4, 5, 6]; + tab.floats_visible = true; + tab.active = 3; + let rect = |x, y| Rect::from_min_size(egui::pos2(x, y), egui::vec2(80.0, 80.0)); + let rects = [ + (1, rect(50.0, 0.0)), + (2, rect(0.0, 50.0)), + (3, rect(0.0, 0.0)), + (4, rect(100.0, 0.0)), + (5, rect(100.0, 100.0)), + (6, rect(0.0, 100.0)), + ]; + for (direction, expected) in [ + (Direction::Right, 4), + (Direction::Down, 5), + (Direction::Left, 6), + (Direction::Up, 3), + ] { + tab.focus_direction(direction, &rects); + assert_eq!(tab.active, expected); + } + tab.focus_direction(Direction::Left, &rects); + assert_eq!(tab.active, 3); + tab.cycle(true); + assert_eq!(tab.active, 6); + tab.cycle(false); + assert_eq!(tab.active, 3); + tab.floats = vec![3]; + tab.focus_direction(Direction::Right, &rects); + assert_eq!(tab.active, 3); + tab.focus_direction(Direction::Down, &rects); + assert_eq!(tab.active, 3); + tab.cycle(false); + assert_eq!(tab.active, 3); + tab.floats_visible = false; + tab.cycle(false); + assert_eq!(tab.active, 1); + tab.cycle(false); + assert_eq!(tab.active, 2); + } } diff --git a/src/pane.rs b/src/pane.rs index 0acfa4f..0555978 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2,7 +2,7 @@ use crate::{ config::{Config, rgb}, input, pty::Pty, - render::Renderer, + render::{Renderer, cell_size}, }; use anyhow::{Result, ensure}; use egui::{FontId, Rect, Sense, vec2}; @@ -109,9 +109,13 @@ impl Pane { pub fn drain(&mut self, ctx: &egui::Context) { // Bound work per frame as well as queued bytes, so one busy PTY cannot starve others. + let mut drained = 0; for _ in 0..64 { match self.pty.output.try_recv() { - Ok(data) => self.terminal.vt_write(&data), + Ok(data) => { + self.terminal.vt_write(&data); + drained += 1; + } Err(TryRecvError::Empty | TryRecvError::Disconnected) => break, } } @@ -124,8 +128,10 @@ impl Pane { if let Some(e) = self.callback_error.borrow_mut().take() { self.error = Some(e); } - if !self.dead { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); + // Workers wake the UI on output/exit/errors. Only poll again if we hit + // the read budget and may have left queued output behind. + if drained == 64 { + ctx.request_repaint(); } } @@ -185,15 +191,8 @@ impl Pane { active: bool, ) -> Result { let font = FontId::monospace(config.font_size); - let cw = ui - .painter() - .layout_no_wrap("M".into(), font.clone(), egui::Color32::WHITE) - .size() - .x - .ceil() - .max(1.0); - let ch = (config.font_size * 1.3).ceil(); - let cell = vec2(cw, ch); + let cell = cell_size(ui.painter(), &font); + let (cw, ch) = (cell.x, cell.y); let grid = rect.shrink(6.0); if grid.width() < cw || grid.height() < ch { return Ok(false); diff --git a/src/render.rs b/src/render.rs index 8aab511..d2ec259 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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 { + 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::>() + }; + 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, + foregrounds: Vec, + selection: Option<(u16, u16)>, +} + +fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc { + 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, + row_cache: Vec, + glyphs: HashMap>, + font_marker: Option>, + cached_colors: Option, + 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 { + 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::(); + 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() + ); + } + }); + } + } +}