flake
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# Omit shell to use $SHELL. Arguments are separate array entries.
|
# Omit shell to use $SHELL. Arguments are separate array entries.
|
||||||
# shell = ["/bin/bash", "--login"]
|
# shell = ["/bin/bash", "--login"]
|
||||||
# working_directory = "/home/you"
|
# 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
|
font_size = 16
|
||||||
scrollback = 10000
|
scrollback = 10000
|
||||||
window_width = 1100
|
window_width = 1100
|
||||||
|
|||||||
Generated
+45
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
+111
-7
@@ -76,7 +76,8 @@ impl App {
|
|||||||
let active = self.tabs[self.current].active;
|
let active = self.tabs[self.current].active;
|
||||||
match action {
|
match action {
|
||||||
Action::NewPane => {
|
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
|
Action::NewFloating
|
||||||
} else {
|
} else {
|
||||||
let rect = self.panes[&active].last_rect;
|
let rect = self.panes[&active].last_rect;
|
||||||
@@ -195,20 +196,43 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn close(&mut self, id: PaneId, ctx: &egui::Context) {
|
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 {
|
for tab in &mut self.tabs {
|
||||||
if !tab.ids().contains(&id) {
|
if !tab.ids().contains(&id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let was_floating = tab.floats.contains(&id);
|
||||||
tab.detach(id);
|
tab.detach(id);
|
||||||
if tab.active == id
|
|
||||||
&& let Some(next) = tab.ids().first()
|
|
||||||
{
|
|
||||||
tab.active = *next;
|
|
||||||
}
|
|
||||||
if tab.root.is_none() {
|
if tab.root.is_none() {
|
||||||
tab.floats_visible = true;
|
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.tabs.retain(|t| !t.ids().is_empty());
|
||||||
self.current = self.current.min(self.tabs.len().saturating_sub(1));
|
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<egui::Event>) -> Result<()> {
|
fn flush_keys(&mut self, events: &mut Vec<egui::Event>) -> Result<()> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let active = self.tabs[self.current].active;
|
let active = self.tabs[self.current].active;
|
||||||
if !self.tabs[self.current].is_visible(active) {
|
if !self.tabs[self.current].is_visible(active) {
|
||||||
events.clear();
|
events.clear();
|
||||||
@@ -603,4 +630,81 @@ mod tests {
|
|||||||
assert_eq!(app.tabs[0].floats, vec![3]);
|
assert_eq!(app.tabs[0].floats, vec![3]);
|
||||||
assert!(app.tabs[0].floats_visible);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-8
@@ -177,12 +177,26 @@ impl Tab {
|
|||||||
pub fn detach(&mut self, id: PaneId) {
|
pub fn detach(&mut self, id: PaneId) {
|
||||||
self.root = self.root.take().and_then(|r| r.remove(id));
|
self.root = self.root.take().and_then(|r| r.remove(id));
|
||||||
self.floats.retain(|p| *p != id);
|
self.floats.retain(|p| *p != id);
|
||||||
|
if self.floats.is_empty() {
|
||||||
|
self.floats_visible = false;
|
||||||
|
}
|
||||||
self.zoomed = false;
|
self.zoomed = false;
|
||||||
}
|
}
|
||||||
pub fn is_visible(&self, id: PaneId) -> bool {
|
pub fn is_visible(&self, id: PaneId) -> bool {
|
||||||
self.floats_visible || !self.floats.contains(&id)
|
self.floats_visible || !self.floats.contains(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn navigation_ids(&self) -> Vec<PaneId> {
|
||||||
|
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) {
|
pub fn toggle_floats(&mut self) {
|
||||||
self.floats_visible = !self.floats_visible;
|
self.floats_visible = !self.floats_visible;
|
||||||
self.zoomed = false;
|
self.zoomed = false;
|
||||||
@@ -213,7 +227,7 @@ impl Tab {
|
|||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
let ids = self.ids();
|
let ids = self.navigation_ids();
|
||||||
let next = rects
|
let next = rects
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(id, rect)| {
|
.filter_map(|(id, rect)| {
|
||||||
@@ -248,16 +262,14 @@ impl Tab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn cycle(&mut self, backwards: bool) {
|
pub fn cycle(&mut self, backwards: bool) {
|
||||||
let ids: Vec<_> = self
|
let ids = self.navigation_ids();
|
||||||
.ids()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|id| self.floats_visible || !self.floats.contains(id))
|
|
||||||
.collect();
|
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pos = ids.iter().position(|p| *p == self.active).unwrap_or(0);
|
self.active = match ids.iter().position(|p| *p == self.active) {
|
||||||
self.active = ids[(pos + if backwards { ids.len() - 1 } else { 1 }) % ids.len()];
|
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()
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-13
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
config::{Config, rgb},
|
config::{Config, rgb},
|
||||||
input,
|
input,
|
||||||
pty::Pty,
|
pty::Pty,
|
||||||
render::Renderer,
|
render::{Renderer, cell_size},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{Result, ensure};
|
||||||
use egui::{FontId, Rect, Sense, vec2};
|
use egui::{FontId, Rect, Sense, vec2};
|
||||||
@@ -109,9 +109,13 @@ impl Pane {
|
|||||||
|
|
||||||
pub fn drain(&mut self, ctx: &egui::Context) {
|
pub fn drain(&mut self, ctx: &egui::Context) {
|
||||||
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
|
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
|
||||||
|
let mut drained = 0;
|
||||||
for _ in 0..64 {
|
for _ in 0..64 {
|
||||||
match self.pty.output.try_recv() {
|
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,
|
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,8 +128,10 @@ impl Pane {
|
|||||||
if let Some(e) = self.callback_error.borrow_mut().take() {
|
if let Some(e) = self.callback_error.borrow_mut().take() {
|
||||||
self.error = Some(e);
|
self.error = Some(e);
|
||||||
}
|
}
|
||||||
if !self.dead {
|
// Workers wake the UI on output/exit/errors. Only poll again if we hit
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
// the read budget and may have left queued output behind.
|
||||||
|
if drained == 64 {
|
||||||
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +191,8 @@ impl Pane {
|
|||||||
active: bool,
|
active: bool,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let font = FontId::monospace(config.font_size);
|
let font = FontId::monospace(config.font_size);
|
||||||
let cw = ui
|
let cell = cell_size(ui.painter(), &font);
|
||||||
.painter()
|
let (cw, ch) = (cell.x, cell.y);
|
||||||
.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 grid = rect.shrink(6.0);
|
let grid = rect.shrink(6.0);
|
||||||
if grid.width() < cw || grid.height() < ch {
|
if grid.width() < cw || grid.height() < ch {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
|
|||||||
+407
-8
@@ -6,18 +6,67 @@ use libghostty_vt::{
|
|||||||
RenderState, Terminal,
|
RenderState, Terminal,
|
||||||
alloc::{Allocator, Bytes},
|
alloc::{Allocator, Bytes},
|
||||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||||
render::{CellIterator, CursorVisualStyle, Dirty, RowIterator},
|
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
||||||
style::{RgbColor, Underline},
|
style::{RgbColor, Underline},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
io::Cursor,
|
io::Cursor,
|
||||||
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn color(c: RgbColor) -> Color32 {
|
pub fn color(c: RgbColor) -> Color32 {
|
||||||
Color32::from_rgb(c.r, c.g, c.b)
|
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;
|
pub struct PngDecoder;
|
||||||
impl DecodePng for PngDecoder {
|
impl DecodePng for PngDecoder {
|
||||||
fn decode_png<'a>(
|
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 {
|
pub struct Renderer {
|
||||||
state: RenderState<'static>,
|
state: RenderState<'static>,
|
||||||
rows: RowIterator<'static>,
|
rows: RowIterator<'static>,
|
||||||
cells: CellIterator<'static>,
|
cells: CellIterator<'static>,
|
||||||
placements: PlacementIterator<'static>,
|
placements: PlacementIterator<'static>,
|
||||||
textures: HashMap<u32, (u64, TextureHandle)>,
|
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 {
|
impl Renderer {
|
||||||
@@ -87,6 +159,14 @@ impl Renderer {
|
|||||||
cells: CellIterator::new()?,
|
cells: CellIterator::new()?,
|
||||||
placements: PlacementIterator::new()?,
|
placements: PlacementIterator::new()?,
|
||||||
textures: HashMap::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,
|
active: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let painter = painter.with_clip_rect(rect.intersect(painter.clip_rect()));
|
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 snapshot = self.state.update(term)?;
|
||||||
let colors = snapshot.colors()?;
|
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));
|
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 rows = self.rows.update(&snapshot)?;
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut y = 0;
|
let mut y = 0;
|
||||||
while let Some(row) = rows.next() {
|
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 cells = self.cells.update(row)?;
|
||||||
let mut x = 0;
|
let mut x = 0;
|
||||||
while let Some(c) = cells.next() {
|
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 cell_rect = Rect::from_min_size(pos, cell);
|
||||||
let style = c.style()?;
|
let style = c.style()?;
|
||||||
let mut fg = color(c.fg_color()?.unwrap_or(colors.foreground));
|
let mut fg = color(c.fg_color()?.unwrap_or(colors.foreground));
|
||||||
@@ -131,8 +249,16 @@ impl Renderer {
|
|||||||
if !style.invisible && c.graphemes_len()? > 0 {
|
if !style.invisible && c.graphemes_len()? > 0 {
|
||||||
c.graphemes_utf8(&mut text)?;
|
c.graphemes_utf8(&mut text)?;
|
||||||
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
||||||
if !text.starts_with('\u{10eeee}') {
|
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
||||||
let galley = painter.layout_no_wrap(text.clone(), font.clone(), 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));
|
foregrounds.push(egui::Shape::galley(pos, galley.clone(), fg));
|
||||||
if style.bold {
|
if style.bold {
|
||||||
foregrounds.push(egui::Shape::galley(
|
foregrounds.push(egui::Shape::galley(
|
||||||
@@ -157,6 +283,7 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
x += 1;
|
x += 1;
|
||||||
}
|
}
|
||||||
|
self.row_cache[y] = cached;
|
||||||
row.set_dirty(false)?;
|
row.set_dirty(false)?;
|
||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
@@ -164,10 +291,28 @@ impl Renderer {
|
|||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
|
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
|
||||||
if layer == Layer::BelowText {
|
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 {
|
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(
|
paint_images(
|
||||||
term,
|
term,
|
||||||
@@ -301,3 +446,257 @@ fn paint_images(
|
|||||||
}
|
}
|
||||||
Ok(())
|
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