Compare commits
5
Commits
d74b0acc83
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9fc8517d | ||
|
|
8d49a993e9 | ||
|
|
c21472b2f2 | ||
|
|
ca30e9a0ee | ||
|
|
c5e4e5fb85 |
Generated
+1
@@ -2319,6 +2319,7 @@ dependencies = [
|
|||||||
"png",
|
"png",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"serde",
|
"serde",
|
||||||
|
"shell-words",
|
||||||
"toml",
|
"toml",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
|
|||||||
png = "0.18"
|
png = "0.18"
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
shell-words = "1"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
|
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
# regular-only fonts retain synthetic bold/italic emphasis.
|
# regular-only fonts retain synthetic bold/italic emphasis.
|
||||||
font_size = 16
|
font_size = 16
|
||||||
scrollback = 10000
|
scrollback = 10000
|
||||||
|
# Command for opening scrollback; omit to use $EDITOR, or vi if unset.
|
||||||
|
# Supports quoted arguments; the scrollback file path is appended automatically.
|
||||||
|
# Runs directly without shell expansion. The editor should stay in the foreground.
|
||||||
|
# scrollback_editor = "nvim -R"
|
||||||
window_width = 1100
|
window_width = 1100
|
||||||
window_height = 720
|
window_height = 720
|
||||||
|
|
||||||
@@ -60,6 +64,7 @@ floating_max_height = 640
|
|||||||
"Alt+F" = "toggle_floats"
|
"Alt+F" = "toggle_floats"
|
||||||
"Alt+M" = "new_pane"
|
"Alt+M" = "new_pane"
|
||||||
"Alt+H" = "focus_left"
|
"Alt+H" = "focus_left"
|
||||||
|
# With floating panes shown, J/K cycle forward/backward through them.
|
||||||
"Alt+J" = "focus_down"
|
"Alt+J" = "focus_down"
|
||||||
"Alt+K" = "focus_up"
|
"Alt+K" = "focus_up"
|
||||||
"Alt+L" = "focus_right"
|
"Alt+L" = "focus_right"
|
||||||
@@ -67,6 +72,8 @@ floating_max_height = 640
|
|||||||
"Alt+Shift+H" = "previous_tab"
|
"Alt+Shift+H" = "previous_tab"
|
||||||
"Alt+Shift+L" = "next_tab"
|
"Alt+Shift+L" = "next_tab"
|
||||||
"Alt+X" = "close_pane"
|
"Alt+X" = "close_pane"
|
||||||
|
# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit.
|
||||||
|
"Alt+S" = "open_scrollback"
|
||||||
"Ctrl+Shift+Z" = "zoom_pane"
|
"Ctrl+Shift+Z" = "zoom_pane"
|
||||||
"Ctrl+Shift+R" = "reload_config"
|
"Ctrl+Shift+R" = "reload_config"
|
||||||
"Ctrl+Shift+C" = "copy"
|
"Ctrl+Shift+C" = "copy"
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
checkFlags = [
|
checkFlags = [
|
||||||
"--test-threads=1"
|
"--test-threads=1"
|
||||||
"--skip=app::tests::"
|
"--skip=app::tests::"
|
||||||
|
"--skip=pane::scrollback_tests::"
|
||||||
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
|
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
|
||||||
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
|
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
|
||||||
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
|
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
|
||||||
|
|||||||
+144
-12
@@ -235,6 +235,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||||
|
Action::OpenScrollback => {
|
||||||
|
self.panes.get_mut(&active).unwrap().open_scrollback(
|
||||||
|
&self.config,
|
||||||
|
&self.theme,
|
||||||
|
ctx,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -425,6 +432,7 @@ impl eframe::App for App {
|
|||||||
("Zoom / restore", Action::ZoomPane),
|
("Zoom / restore", Action::ZoomPane),
|
||||||
("Next pane", Action::NextPane),
|
("Next pane", Action::NextPane),
|
||||||
("Close pane", Action::ClosePane),
|
("Close pane", Action::ClosePane),
|
||||||
|
("Open scrollback in editor", Action::OpenScrollback),
|
||||||
] {
|
] {
|
||||||
if ui.button(label).clicked() {
|
if ui.button(label).clicked() {
|
||||||
action = Some(a);
|
action = Some(a);
|
||||||
@@ -496,15 +504,20 @@ impl eframe::App for App {
|
|||||||
let pane = self.panes.get_mut(&id).unwrap();
|
let pane = self.panes.get_mut(&id).unwrap();
|
||||||
let window_id = egui::Id::new(("float", tab.id, id));
|
let window_id = egui::Id::new(("float", tab.id, id));
|
||||||
let active = tab.active == id;
|
let active = tab.active == id;
|
||||||
let (pos, size) = float_placement(workspace, index, &self.config.multiplexer);
|
let window = egui::Window::new(pane_title(id, pane))
|
||||||
let result = egui::Window::new(pane_title(id, pane))
|
|
||||||
.id(window_id)
|
.id(window_id)
|
||||||
.title_bar(false)
|
.title_bar(false)
|
||||||
.constrain_to(workspace)
|
.constrain_to(workspace)
|
||||||
.default_pos(pos)
|
|
||||||
.default_size(size)
|
|
||||||
.min_size(vec2(160.0, 100.0))
|
.min_size(vec2(160.0, 100.0))
|
||||||
.frame(egui::Frame::NONE.fill(bg))
|
.frame(egui::Frame::NONE.fill(bg));
|
||||||
|
let result = position_float(
|
||||||
|
ctx,
|
||||||
|
window,
|
||||||
|
window_id,
|
||||||
|
index,
|
||||||
|
workspace,
|
||||||
|
&self.config.multiplexer,
|
||||||
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
ui.allocate_rect(rect, egui::Sense::hover());
|
ui.allocate_rect(rect, egui::Sense::hover());
|
||||||
@@ -571,7 +584,7 @@ fn grab_keys(ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Corner rounding shared by a pane's border and its title bar.
|
/// Corner rounding shared by a pane's border and its title bar.
|
||||||
const CORNER: u8 = 5;
|
const CORNER: u8 = theme::CORNER_RADIUS;
|
||||||
|
|
||||||
fn show_pane(
|
fn show_pane(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -638,7 +651,7 @@ fn show_pane(
|
|||||||
rect,
|
rect,
|
||||||
CORNER,
|
CORNER,
|
||||||
Stroke::new(
|
Stroke::new(
|
||||||
1.0_f32,
|
theme::BORDER_WIDTH,
|
||||||
if active {
|
if active {
|
||||||
accent
|
accent
|
||||||
} else {
|
} else {
|
||||||
@@ -653,7 +666,7 @@ fn show_pane(
|
|||||||
/// Where a floating pane opens, from its place in the tab's float list rather than
|
/// Where a floating pane opens, from its place in the tab's float list rather than
|
||||||
/// its pane id: the first one is centred and every later one steps down and right
|
/// its pane id: the first one is centred and every later one steps down and right
|
||||||
/// from it, so the same slot always lands in the same spot. The cascade wraps
|
/// from it, so the same slot always lands in the same spot. The cascade wraps
|
||||||
/// before it can walk a float off the far edge.
|
/// every six panes, with each new stack shifted slightly to the right.
|
||||||
fn float_placement(
|
fn float_placement(
|
||||||
workspace: Rect,
|
workspace: Rect,
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -663,8 +676,43 @@ fn float_placement(
|
|||||||
// terminal far more room than it needs; the maximums keep it in hand.
|
// terminal far more room than it needs; the maximums keep it in hand.
|
||||||
let size = (workspace.size() * vec2(config.floating_width, config.floating_height))
|
let size = (workspace.size() * vec2(config.floating_width, config.floating_height))
|
||||||
.min(vec2(config.floating_max_width, config.floating_max_height));
|
.min(vec2(config.floating_max_width, config.floating_max_height));
|
||||||
|
(workspace.center() - size / 2.0 + float_offset(index), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn float_offset(index: usize) -> egui::Vec2 {
|
||||||
let step = (index % 6) as f32 * 28.0;
|
let step = (index % 6) as f32 * 28.0;
|
||||||
(workspace.center() - size / 2.0 + vec2(step, step), size)
|
let stack = (index / 6) as f32 * 12.0;
|
||||||
|
vec2(step + stack, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reapply the cascade when a pane changes slots or the workspace resizes.
|
||||||
|
/// Otherwise leave egui's remembered position alone so panes can still be dragged.
|
||||||
|
fn position_float<'a>(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
window: egui::Window<'a>,
|
||||||
|
window_id: egui::Id,
|
||||||
|
index: usize,
|
||||||
|
workspace: Rect,
|
||||||
|
config: &config::Multiplexer,
|
||||||
|
) -> egui::Window<'a> {
|
||||||
|
let (pos, size) = float_placement(workspace, index, config);
|
||||||
|
let window = window.default_pos(pos).default_size(size);
|
||||||
|
let changed = ctx.data_mut(|data| {
|
||||||
|
let key = window_id.with("cascade placement");
|
||||||
|
let placement = (index, workspace);
|
||||||
|
let previous = data.get_temp::<(usize, Rect)>(key);
|
||||||
|
data.insert_temp(key, placement);
|
||||||
|
previous.is_some_and(|previous| previous != placement)
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
// Center using the remembered size, including user resizing.
|
||||||
|
let size = egui::AreaState::load(ctx, window_id)
|
||||||
|
.and_then(|state| state.size)
|
||||||
|
.unwrap_or(size);
|
||||||
|
window.current_pos(workspace.center() - size / 2.0 + float_offset(index))
|
||||||
|
} else {
|
||||||
|
window
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pane_title(id: PaneId, pane: &Pane) -> String {
|
fn pane_title(id: PaneId, pane: &Pane) -> String {
|
||||||
@@ -741,6 +789,80 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_floats_repositions_survivors_in_cycle_order() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let config = config::Multiplexer::default();
|
||||||
|
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1200.0));
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.floats = vec![2, 3, 4, 5];
|
||||||
|
tab.floats_visible = true;
|
||||||
|
let render = |tab: &Tab, moved: Option<PaneId>, workspace: Rect| {
|
||||||
|
let mut positions = BTreeMap::new();
|
||||||
|
// Allow egui's initial sizing pass to settle.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = ctx.run(
|
||||||
|
egui::RawInput {
|
||||||
|
screen_rect: Some(workspace),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|ctx| {
|
||||||
|
for (index, &id) in tab.floats.iter().enumerate() {
|
||||||
|
let window_id = egui::Id::new(("float", tab.id, id));
|
||||||
|
let mut window = egui::Window::new(id.to_string())
|
||||||
|
.id(window_id)
|
||||||
|
.title_bar(false)
|
||||||
|
.frame(egui::Frame::NONE);
|
||||||
|
if moved == Some(id) {
|
||||||
|
window = window.current_pos(egui::pos2(100.0, 100.0));
|
||||||
|
}
|
||||||
|
let response =
|
||||||
|
position_float(ctx, window, window_id, index, workspace, &config)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.allocate_rect(
|
||||||
|
ui.available_rect_before_wrap(),
|
||||||
|
egui::Sense::hover(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
positions.insert(id, response.response.rect.min);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
positions
|
||||||
|
};
|
||||||
|
render(&tab, Some(2), workspace);
|
||||||
|
assert_eq!(render(&tab, None, workspace)[&2], egui::pos2(100.0, 100.0));
|
||||||
|
// Resizing recenters even a dragged pane, using its existing size.
|
||||||
|
let resized = Rect::from_min_size(egui::Pos2::ZERO, vec2(1800.0, 1400.0));
|
||||||
|
let positions = render(&tab, None, resized);
|
||||||
|
for (index, &id) in tab.floats.iter().enumerate() {
|
||||||
|
assert_eq!(positions[&id], float_placement(resized, index, &config).0);
|
||||||
|
}
|
||||||
|
render(&tab, None, workspace);
|
||||||
|
render(&tab, Some(2), workspace);
|
||||||
|
// Remove the middle pane, then the first, then append a new float.
|
||||||
|
for (removed, added) in [(3, None), (2, None), (4, Some(6))] {
|
||||||
|
tab.detach(removed);
|
||||||
|
if let Some(id) = added {
|
||||||
|
tab.floats.push(id);
|
||||||
|
}
|
||||||
|
let positions = render(&tab, None, workspace);
|
||||||
|
tab.active = tab.floats[0];
|
||||||
|
for index in 0..tab.floats.len() {
|
||||||
|
let expected = if tab.active == 2 {
|
||||||
|
egui::pos2(100.0, 100.0)
|
||||||
|
} else {
|
||||||
|
float_placement(workspace, index, &config).0
|
||||||
|
};
|
||||||
|
assert_eq!(positions[&tab.active], expected);
|
||||||
|
tab.cycle(false);
|
||||||
|
}
|
||||||
|
assert_eq!(tab.active, tab.floats[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
|
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
|
||||||
let config = config::Multiplexer::default();
|
let config = config::Multiplexer::default();
|
||||||
@@ -762,9 +884,19 @@ mod tests {
|
|||||||
second.x > first.x && second.y > first.y,
|
second.x > first.x && second.y > first.y,
|
||||||
"later floats step off it"
|
"later floats step off it"
|
||||||
);
|
);
|
||||||
// Six slots later the cascade is back where it started, so it cannot walk away.
|
// Each group of six restarts the cascade slightly to the right.
|
||||||
assert_eq!(float_placement(workspace, 6, &config).0, first);
|
assert_eq!(
|
||||||
assert_eq!(float_placement(workspace, 7, &config).0, second);
|
float_placement(workspace, 6, &config).0,
|
||||||
|
first + vec2(12.0, 0.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
float_placement(workspace, 7, &config).0,
|
||||||
|
second + vec2(12.0, 0.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
float_placement(workspace, 12, &config).0,
|
||||||
|
first + vec2(24.0, 0.0)
|
||||||
|
);
|
||||||
|
|
||||||
// A small workspace keeps the fractions instead of the maximums.
|
// A small workspace keeps the fractions instead of the maximums.
|
||||||
let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
|
let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct Config {
|
|||||||
pub font_bold_italic_path: Option<PathBuf>,
|
pub font_bold_italic_path: Option<PathBuf>,
|
||||||
pub font_fallbacks: Vec<String>,
|
pub font_fallbacks: Vec<String>,
|
||||||
pub scrollback: usize,
|
pub scrollback: usize,
|
||||||
|
pub scrollback_editor: Option<String>,
|
||||||
pub window_width: f32,
|
pub window_width: f32,
|
||||||
pub window_height: f32,
|
pub window_height: f32,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
@@ -77,6 +78,7 @@ pub enum Action {
|
|||||||
ReloadConfig,
|
ReloadConfig,
|
||||||
Copy,
|
Copy,
|
||||||
Paste,
|
Paste,
|
||||||
|
OpenScrollback,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Kitty {
|
impl Default for Kitty {
|
||||||
@@ -115,6 +117,7 @@ impl Default for Config {
|
|||||||
font_bold_italic_path: None,
|
font_bold_italic_path: None,
|
||||||
font_fallbacks: Vec::new(),
|
font_fallbacks: Vec::new(),
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
|
scrollback_editor: None,
|
||||||
window_width: 1100.0,
|
window_width: 1100.0,
|
||||||
window_height: 720.0,
|
window_height: 720.0,
|
||||||
theme: theme::DEFAULT_NAME.into(),
|
theme: theme::DEFAULT_NAME.into(),
|
||||||
@@ -135,6 +138,7 @@ impl Default for Config {
|
|||||||
("Alt+Shift+H", Action::PreviousTab),
|
("Alt+Shift+H", Action::PreviousTab),
|
||||||
("Alt+Shift+L", Action::NextTab),
|
("Alt+Shift+L", Action::NextTab),
|
||||||
("Alt+X", Action::ClosePane),
|
("Alt+X", Action::ClosePane),
|
||||||
|
("Alt+S", Action::OpenScrollback),
|
||||||
("Ctrl+Shift+Z", Action::ZoomPane),
|
("Ctrl+Shift+Z", Action::ZoomPane),
|
||||||
("Ctrl+Shift+R", Action::ReloadConfig),
|
("Ctrl+Shift+R", Action::ReloadConfig),
|
||||||
("Ctrl+Shift+C", Action::Copy),
|
("Ctrl+Shift+C", Action::Copy),
|
||||||
@@ -186,6 +190,14 @@ impl Config {
|
|||||||
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
||||||
"shell must contain an executable"
|
"shell must contain an executable"
|
||||||
);
|
);
|
||||||
|
if let Some(editor) = &config.scrollback_editor {
|
||||||
|
let command =
|
||||||
|
shell_words::split(editor).context("Invalid scrollback_editor command")?;
|
||||||
|
ensure!(
|
||||||
|
!command.is_empty() && !command[0].is_empty(),
|
||||||
|
"scrollback_editor must contain an executable"
|
||||||
|
);
|
||||||
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
(8.0..=48.0).contains(&config.font_size),
|
(8.0..=48.0).contains(&config.font_size),
|
||||||
"font_size must be 8..48"
|
"font_size must be 8..48"
|
||||||
@@ -307,6 +319,10 @@ mod tests {
|
|||||||
"font_fallbacks = [' ']",
|
"font_fallbacks = [' ']",
|
||||||
"font_family = 'Test'\nfont_path = '/font.ttf'",
|
"font_family = 'Test'\nfont_path = '/font.ttf'",
|
||||||
"shell = []",
|
"shell = []",
|
||||||
|
"scrollback_editor = ''",
|
||||||
|
"scrollback_editor = ' '",
|
||||||
|
"scrollback_editor = \"''\"",
|
||||||
|
"scrollback_editor = \"'\"",
|
||||||
"background = '#xx0000'",
|
"background = '#xx0000'",
|
||||||
"font_sze = 16",
|
"font_sze = 16",
|
||||||
"[keys]\n'Hyper+Q' = 'close_pane'",
|
"[keys]\n'Hyper+Q' = 'close_pane'",
|
||||||
@@ -330,6 +346,7 @@ mod tests {
|
|||||||
("Alt+Shift+H", Action::PreviousTab),
|
("Alt+Shift+H", Action::PreviousTab),
|
||||||
("Alt+Shift+L", Action::NextTab),
|
("Alt+Shift+L", Action::NextTab),
|
||||||
("Alt+X", Action::ClosePane),
|
("Alt+X", Action::ClosePane),
|
||||||
|
("Alt+S", Action::OpenScrollback),
|
||||||
] {
|
] {
|
||||||
assert_eq!(config.keys.get(binding), Some(&action));
|
assert_eq!(config.keys.get(binding), Some(&action));
|
||||||
assert!(shortcut(binding).unwrap().modifiers.alt);
|
assert!(shortcut(binding).unwrap().modifiers.alt);
|
||||||
|
|||||||
+21
-2
@@ -1,6 +1,25 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use libghostty_vt::{Terminal, key};
|
use libghostty_vt::{Terminal, key};
|
||||||
|
|
||||||
|
/// Avoid libghostty-vt 0.2.1's encode_to_vec capacity calculation, which can
|
||||||
|
/// reserve too little when the destination already has some spare capacity.
|
||||||
|
pub fn append_encoded(
|
||||||
|
out: &mut Vec<u8>,
|
||||||
|
mut encode: impl FnMut(&mut [u8]) -> libghostty_vt::error::Result<usize>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut buffer = [0; 128];
|
||||||
|
match encode(&mut buffer) {
|
||||||
|
Ok(written) => out.extend_from_slice(&buffer[..written]),
|
||||||
|
Err(libghostty_vt::Error::OutOfSpace { required }) => {
|
||||||
|
let mut buffer = vec![0; required];
|
||||||
|
let written = encode(&mut buffer)?;
|
||||||
|
out.extend_from_slice(&buffer[..written]);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mods(m: egui::Modifiers) -> key::Mods {
|
pub fn mods(m: egui::Modifiers) -> key::Mods {
|
||||||
let mut out = key::Mods::empty();
|
let mut out = key::Mods::empty();
|
||||||
if m.ctrl {
|
if m.ctrl {
|
||||||
@@ -71,7 +90,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
|
|||||||
})
|
})
|
||||||
.set_unshifted_codepoint(ucp)
|
.set_unshifted_codepoint(ucp)
|
||||||
.set_utf8(text);
|
.set_utf8(text);
|
||||||
encoder.encode_to_vec(&event, &mut out)?;
|
append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
|
||||||
}
|
}
|
||||||
egui::Event::Text(text) if !used_text.contains(&index) => {
|
egui::Event::Text(text) if !used_text.contains(&index) => {
|
||||||
event
|
event
|
||||||
@@ -81,7 +100,7 @@ pub fn encode(terminal: &Terminal<'_, '_>, events: &[egui::Event]) -> Result<Vec
|
|||||||
.set_consumed_mods(key::Mods::empty())
|
.set_consumed_mods(key::Mods::empty())
|
||||||
.set_unshifted_codepoint('\0')
|
.set_unshifted_codepoint('\0')
|
||||||
.set_utf8(Some(text.as_str()));
|
.set_utf8(Some(text.as_str()));
|
||||||
encoder.encode_to_vec(&event, &mut out)?;
|
append_encoded(&mut out, |buffer| encoder.encode(&event, buffer))?;
|
||||||
}
|
}
|
||||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
|
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
|
||||||
out.extend_from_slice(text.as_bytes())
|
out.extend_from_slice(text.as_bytes())
|
||||||
|
|||||||
+145
-6
@@ -152,6 +152,8 @@ pub struct Tab {
|
|||||||
pub floats: Vec<PaneId>,
|
pub floats: Vec<PaneId>,
|
||||||
pub active: PaneId,
|
pub active: PaneId,
|
||||||
pub floats_visible: bool,
|
pub floats_visible: bool,
|
||||||
|
// Remember both focus and zoom while floating panes are hidden.
|
||||||
|
last_active_float: Option<(PaneId, bool)>,
|
||||||
pub zoomed: bool,
|
pub zoomed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ impl Tab {
|
|||||||
floats: vec![],
|
floats: vec![],
|
||||||
active: pane,
|
active: pane,
|
||||||
floats_visible: false,
|
floats_visible: false,
|
||||||
|
last_active_float: None,
|
||||||
zoomed: false,
|
zoomed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +180,9 @@ 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.last_active_float.is_some_and(|(pane, _)| pane == id) {
|
||||||
|
self.last_active_float = None;
|
||||||
|
}
|
||||||
if self.floats.is_empty() {
|
if self.floats.is_empty() {
|
||||||
self.floats_visible = false;
|
self.floats_visible = false;
|
||||||
}
|
}
|
||||||
@@ -199,19 +205,32 @@ impl Tab {
|
|||||||
|
|
||||||
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;
|
let was_zoomed = std::mem::take(&mut self.zoomed);
|
||||||
if self.floats_visible {
|
if self.floats_visible {
|
||||||
if let Some(id) = self.floats.last() {
|
if let Some((id, zoomed)) = self
|
||||||
self.active = *id;
|
.last_active_float
|
||||||
}
|
.filter(|(id, _)| self.floats.contains(id))
|
||||||
} else if self.floats.contains(&self.active)
|
.or_else(|| self.floats.last().map(|id| (*id, false)))
|
||||||
&& let Some(root) = &self.root
|
|
||||||
{
|
{
|
||||||
|
self.active = id;
|
||||||
|
self.zoomed = zoomed;
|
||||||
|
}
|
||||||
|
} else if self.floats.contains(&self.active) {
|
||||||
|
self.last_active_float = Some((self.active, was_zoomed));
|
||||||
|
if let Some(root) = &self.root {
|
||||||
self.active = root.first();
|
self.active = root.first();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
|
pub fn focus_direction(&mut self, direction: Direction, rects: &[(PaneId, Rect)]) {
|
||||||
|
if self.floats_visible
|
||||||
|
&& !self.floats.is_empty()
|
||||||
|
&& matches!(direction, Direction::Down | Direction::Up)
|
||||||
|
{
|
||||||
|
self.cycle(matches!(direction, Direction::Up));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some((_, source)) = rects
|
let Some((_, source)) = rects
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(id, _)| *id == self.active && self.is_visible(*id))
|
.find(|(id, _)| *id == self.active && self.is_visible(*id))
|
||||||
@@ -322,6 +341,100 @@ mod tests {
|
|||||||
assert!(tab.is_visible(2));
|
assert!(tab.is_visible(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn showing_floats_restores_the_pane_active_when_hidden() {
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
|
||||||
|
tab.floats = vec![3, 4, 5];
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 5);
|
||||||
|
tab.cycle(true);
|
||||||
|
assert_eq!(tab.active, 4);
|
||||||
|
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 1);
|
||||||
|
tab.cycle(false);
|
||||||
|
assert_eq!(tab.active, 2);
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 4);
|
||||||
|
|
||||||
|
tab.cycle(true);
|
||||||
|
tab.toggle_floats();
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 3);
|
||||||
|
|
||||||
|
tab.root = None;
|
||||||
|
tab.toggle_floats();
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn showing_floats_falls_back_when_remembered_pane_is_detached() {
|
||||||
|
for dock in [false, true] {
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.floats = vec![2, 3];
|
||||||
|
tab.floats_visible = true;
|
||||||
|
tab.active = 2;
|
||||||
|
tab.zoomed = true;
|
||||||
|
tab.toggle_floats();
|
||||||
|
tab.detach(2);
|
||||||
|
if dock {
|
||||||
|
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
|
||||||
|
}
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 3);
|
||||||
|
assert!(!tab.zoomed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn showing_floats_restores_zoom_without_zooming_tiles() {
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
|
||||||
|
tab.floats = vec![3, 4];
|
||||||
|
tab.floats_visible = true;
|
||||||
|
tab.active = 3;
|
||||||
|
tab.zoomed = true;
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 1);
|
||||||
|
assert!(!tab.zoomed);
|
||||||
|
assert!(!tab.is_visible(3));
|
||||||
|
tab.cycle(false);
|
||||||
|
assert_eq!(tab.active, 2);
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 3);
|
||||||
|
assert!(tab.zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unzooming the float replaces its remembered zoom, even if tiles zoom.
|
||||||
|
tab.zoomed = false;
|
||||||
|
tab.toggle_floats();
|
||||||
|
tab.zoomed = true;
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 3);
|
||||||
|
assert!(!tab.zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floating_zoom_survives_hiding_without_tiles() {
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.root = None;
|
||||||
|
tab.floats = vec![1];
|
||||||
|
tab.floats_visible = true;
|
||||||
|
tab.zoomed = true;
|
||||||
|
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert!(!tab.is_visible(1));
|
||||||
|
assert!(!tab.zoomed);
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 1);
|
||||||
|
assert!(tab.is_visible(1));
|
||||||
|
assert!(tab.zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
|
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
|
||||||
let mut tab = Tab::new(1, 1);
|
let mut tab = Tab::new(1, 1);
|
||||||
@@ -375,6 +488,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_and_down_cycle_visible_floats_without_geometry() {
|
||||||
|
let mut tab = Tab::new(1, 1);
|
||||||
|
tab.floats = vec![2, 3, 4];
|
||||||
|
tab.floats_visible = true;
|
||||||
|
for (direction, expected) in [
|
||||||
|
(Direction::Down, 2),
|
||||||
|
(Direction::Down, 3),
|
||||||
|
(Direction::Down, 4),
|
||||||
|
(Direction::Down, 2),
|
||||||
|
(Direction::Up, 4),
|
||||||
|
(Direction::Up, 3),
|
||||||
|
(Direction::Up, 2),
|
||||||
|
] {
|
||||||
|
tab.focus_direction(direction, &[]);
|
||||||
|
assert_eq!(tab.active, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 1);
|
||||||
|
tab.toggle_floats();
|
||||||
|
assert_eq!(tab.active, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn visible_floats_constrain_directional_and_cyclic_navigation() {
|
fn visible_floats_constrain_directional_and_cyclic_navigation() {
|
||||||
let mut tab = Tab::new(1, 1);
|
let mut tab = Tab::new(1, 1);
|
||||||
@@ -395,6 +532,8 @@ mod tests {
|
|||||||
(Direction::Right, 4),
|
(Direction::Right, 4),
|
||||||
(Direction::Down, 5),
|
(Direction::Down, 5),
|
||||||
(Direction::Left, 6),
|
(Direction::Left, 6),
|
||||||
|
(Direction::Up, 5),
|
||||||
|
(Direction::Up, 4),
|
||||||
(Direction::Up, 3),
|
(Direction::Up, 3),
|
||||||
] {
|
] {
|
||||||
tab.focus_direction(direction, &rects);
|
tab.focus_direction(direction, &rects);
|
||||||
|
|||||||
+277
-14
@@ -5,7 +5,7 @@ use crate::{
|
|||||||
render::{Grid, Renderer, cell_size},
|
render::{Grid, Renderer, cell_size},
|
||||||
theme::{self, Theme},
|
theme::{self, Theme},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
use egui::{FontId, Rect, Sense, vec2};
|
use egui::{FontId, Rect, Sense, vec2};
|
||||||
use libghostty_vt::{
|
use libghostty_vt::{
|
||||||
Terminal, TerminalOptions, mouse,
|
Terminal, TerminalOptions, mouse,
|
||||||
@@ -31,8 +31,193 @@ pub struct Pane {
|
|||||||
callback_error: Rc<RefCell<Option<String>>>,
|
callback_error: Rc<RefCell<Option<String>>>,
|
||||||
mouse_encoder: mouse::Encoder<'static>,
|
mouse_encoder: mouse::Encoder<'static>,
|
||||||
mouse_event: mouse::Event<'static>,
|
mouse_event: mouse::Event<'static>,
|
||||||
|
scrollback_scroll: ScrollAccumulator,
|
||||||
|
mouse_scroll: ScrollAccumulator,
|
||||||
selection_start: Option<PointCoordinate>,
|
selection_start: Option<PointCoordinate>,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
|
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollbackFile(PathBuf);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ScrollAccumulator(f32);
|
||||||
|
|
||||||
|
impl ScrollAccumulator {
|
||||||
|
fn rows(&mut self, delta: f32) -> isize {
|
||||||
|
self.0 += delta;
|
||||||
|
let rows = self.0.trunc();
|
||||||
|
self.0 -= rows;
|
||||||
|
rows as isize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wheel(&mut self, unit: egui::MouseWheelUnit, delta: f32, cell: f32, page: u16) -> isize {
|
||||||
|
self.rows(match unit {
|
||||||
|
egui::MouseWheelUnit::Point => delta / cell,
|
||||||
|
egui::MouseWheelUnit::Line => delta * 3.0,
|
||||||
|
egui::MouseWheelUnit::Page => delta * f32::from(page),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollbackFile {
|
||||||
|
fn new(bytes: &[u8]) -> Result<Self> {
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::Write,
|
||||||
|
os::unix::fs::OpenOptionsExt,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
loop {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"slerm-scrollback-{}-{}.txt",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
match OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(mut file) => {
|
||||||
|
let temporary = Self(path);
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
return Ok(temporary);
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scroll_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn precise_scrolling_preserves_distance_across_frames() {
|
||||||
|
let mut scroll = ScrollAccumulator::default();
|
||||||
|
// Sub-pixel events from a slow gesture must eventually move a row.
|
||||||
|
let rows: isize = (0..128).map(|_| scroll.rows(0.25 / 16.0)).sum();
|
||||||
|
assert_eq!(rows, 2);
|
||||||
|
assert_eq!(scroll.0, 0.0);
|
||||||
|
|
||||||
|
let mut batched = ScrollAccumulator::default();
|
||||||
|
assert_eq!(batched.rows(32.0 / 16.0), rows);
|
||||||
|
assert_eq!(scroll.rows(1.75), 1);
|
||||||
|
assert_eq!(scroll.rows(0.25), 1);
|
||||||
|
assert_eq!(scroll.rows(-0.75), 0);
|
||||||
|
assert_eq!(scroll.rows(-0.25), -1);
|
||||||
|
assert_eq!(scroll.rows(0.75), 0);
|
||||||
|
assert_eq!(scroll.rows(-0.75), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_tracking_respects_precise_distance_and_wheel_units() {
|
||||||
|
use egui::MouseWheelUnit::{Line, Page, Point};
|
||||||
|
let mut scroll = ScrollAccumulator::default();
|
||||||
|
assert_eq!(scroll.wheel(Point, 0.0, 16.0, 24), 0);
|
||||||
|
assert_eq!(scroll.wheel(Point, 4.0, 16.0, 24), 0);
|
||||||
|
assert_eq!(scroll.wheel(Point, 12.0, 16.0, 24), 1);
|
||||||
|
assert_eq!(scroll.wheel(Point, -32.0, 16.0, 24), -2);
|
||||||
|
assert_eq!(scroll.wheel(Line, 1.0, 16.0, 24), 3);
|
||||||
|
assert_eq!(scroll.wheel(Page, -1.0, 16.0, 24), -24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scrollback_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_receives_history_and_restores_original_session_on_exit() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let config = Config {
|
||||||
|
shell: vec![
|
||||||
|
"/bin/sh".into(),
|
||||||
|
"-c".into(),
|
||||||
|
"read answer; printf ORIGINAL_ALIVE; read answer".into(),
|
||||||
|
],
|
||||||
|
scrollback_editor: Config::parse(
|
||||||
|
"scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.scrollback_editor,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
|
||||||
|
pane.terminal.resize(12, 2, 8, 16).unwrap();
|
||||||
|
pane.terminal
|
||||||
|
.vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast");
|
||||||
|
pane.open_scrollback(&config, &theme::DEFAULT, &ctx)
|
||||||
|
.unwrap();
|
||||||
|
let (original, file) = pane.saved_session.as_ref().unwrap();
|
||||||
|
assert!(!original.dead);
|
||||||
|
let path = file.0.clone();
|
||||||
|
let contents = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("history\nred\nlong line that wraps\nlast"),
|
||||||
|
"{contents:?}"
|
||||||
|
);
|
||||||
|
assert!(!contents.contains('\x1b'));
|
||||||
|
pane.send(b"done\n".to_vec()).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while pane.saved_session.is_some() && Instant::now() < deadline {
|
||||||
|
pane.drain(&ctx);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(pane.saved_session.is_none());
|
||||||
|
assert!(!pane.dead);
|
||||||
|
assert!(!path.exists());
|
||||||
|
pane.send(b"continue\n".to_vec()).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
pane.drain(&ctx);
|
||||||
|
let text = libghostty_vt::fmt::Formatter::new(
|
||||||
|
&pane.terminal,
|
||||||
|
libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.format_alloc(None)
|
||||||
|
.unwrap();
|
||||||
|
if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"original process did not resume input"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_editor_leaves_the_original_pane_intact() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let config = Config {
|
||||||
|
shell: vec!["/bin/sh".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
|
||||||
|
for editor in ["", "'", "/no/such/slerm-editor"] {
|
||||||
|
assert!(
|
||||||
|
pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(pane.saved_session.is_none());
|
||||||
|
assert!(!pane.dead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ScrollbackFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
|
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
|
||||||
@@ -100,6 +285,8 @@ pub fn terminal(config: &Config, theme: &Theme) -> Result<Terminal<'static, 'sta
|
|||||||
})?;
|
})?;
|
||||||
term.resize(80, 24, 8, 16)?;
|
term.resize(80, 24, 8, 16)?;
|
||||||
apply_config(&mut term, config, theme)?;
|
apply_config(&mut term, config, theme)?;
|
||||||
|
term.set_default_cursor_style(Some(terminal::CursorStyle::BlockHollow))?;
|
||||||
|
term.vt_write(b"\x1b[0 q");
|
||||||
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
|
term.on_xtversion(|_| Some(concat!("slerm ", env!("CARGO_PKG_VERSION"))))?;
|
||||||
term.on_device_attributes(|_| {
|
term.on_device_attributes(|_| {
|
||||||
Some(terminal::DeviceAttributes {
|
Some(terminal::DeviceAttributes {
|
||||||
@@ -177,12 +364,18 @@ impl Pane {
|
|||||||
callback_error,
|
callback_error,
|
||||||
mouse_encoder,
|
mouse_encoder,
|
||||||
mouse_event,
|
mouse_event,
|
||||||
|
scrollback_scroll: ScrollAccumulator::default(),
|
||||||
|
mouse_scroll: ScrollAccumulator::default(),
|
||||||
selection_start: None,
|
selection_start: None,
|
||||||
focused: false,
|
focused: false,
|
||||||
|
saved_session: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&mut self, ctx: &egui::Context) {
|
pub fn drain(&mut self, ctx: &egui::Context) {
|
||||||
|
if let Some((session, _)) = &mut self.saved_session {
|
||||||
|
session.drain(ctx);
|
||||||
|
}
|
||||||
// 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;
|
let mut drained = 0;
|
||||||
for _ in 0..64 {
|
for _ in 0..64 {
|
||||||
@@ -208,6 +401,66 @@ impl Pane {
|
|||||||
if drained == 64 {
|
if drained == 64 {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if self.dead
|
||||||
|
&& let Some((session, _file)) = self.saved_session.take()
|
||||||
|
{
|
||||||
|
*self = *session;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_scrollback(
|
||||||
|
&mut self,
|
||||||
|
config: &Config,
|
||||||
|
theme: &Theme,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
) -> Result<()> {
|
||||||
|
if self.saved_session.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let editor = config
|
||||||
|
.scrollback_editor
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()));
|
||||||
|
self.open_scrollback_with_editor(config, theme, ctx, &editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_scrollback_with_editor(
|
||||||
|
&mut self,
|
||||||
|
config: &Config,
|
||||||
|
theme: &Theme,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
editor: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use libghostty_vt::fmt::{Formatter, FormatterOptions};
|
||||||
|
let mut command =
|
||||||
|
shell_words::split(editor).context("Invalid scrollback editor command")?;
|
||||||
|
ensure!(
|
||||||
|
!command.is_empty() && !command[0].is_empty(),
|
||||||
|
"Scrollback editor command is empty"
|
||||||
|
);
|
||||||
|
self.drain(ctx);
|
||||||
|
let bytes = Formatter::new(
|
||||||
|
&self.terminal,
|
||||||
|
FormatterOptions::new().with_trim(true).with_unwrap(true),
|
||||||
|
)?
|
||||||
|
.format_alloc(None)?;
|
||||||
|
let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?;
|
||||||
|
command.push(
|
||||||
|
file.0
|
||||||
|
.to_str()
|
||||||
|
.context("Scrollback path is not valid UTF-8")?
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
let mut editor_config = config.clone();
|
||||||
|
editor_config.shell = command;
|
||||||
|
let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref())
|
||||||
|
.context("Cannot open scrollback editor")?;
|
||||||
|
editor_pane.last_rect = self.last_rect;
|
||||||
|
let session = std::mem::replace(self, editor_pane);
|
||||||
|
self.saved_session = Some((Box::new(session), file));
|
||||||
|
ctx.request_repaint();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
|
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||||
@@ -220,6 +473,7 @@ impl Pane {
|
|||||||
.try_send(bytes)
|
.try_send(bytes)
|
||||||
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("PTY input queue: {e}"))?;
|
||||||
self.terminal.scroll_viewport(ScrollViewport::Bottom);
|
self.terminal.scroll_viewport(ScrollViewport::Bottom);
|
||||||
|
self.scrollback_scroll = ScrollAccumulator::default();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +591,12 @@ impl Pane {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let modifiers = ui.input(|i| i.modifiers);
|
let modifiers = ui.input(|i| i.modifiers);
|
||||||
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
|
let tracking = self.terminal.is_mouse_tracking()? && !modifiers.shift;
|
||||||
|
if !response.hovered() || tracking {
|
||||||
|
self.scrollback_scroll = ScrollAccumulator::default();
|
||||||
|
}
|
||||||
|
if !response.hovered() || !tracking {
|
||||||
|
self.mouse_scroll = ScrollAccumulator::default();
|
||||||
|
}
|
||||||
let pos = response
|
let pos = response
|
||||||
.interact_pointer_pos()
|
.interact_pointer_pos()
|
||||||
.or_else(|| ui.input(|i| i.pointer.hover_pos()));
|
.or_else(|| ui.input(|i| i.pointer.hover_pos()));
|
||||||
@@ -385,8 +645,9 @@ impl Pane {
|
|||||||
mouse::Action::Release
|
mouse::Action::Release
|
||||||
})
|
})
|
||||||
.set_button(Some(button));
|
.set_button(Some(button));
|
||||||
self.mouse_encoder
|
input::append_encoded(&mut data, |buffer| {
|
||||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
self.mouse_encoder.encode(&self.mouse_event, buffer)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
egui::Event::PointerMoved(_) => {
|
egui::Event::PointerMoved(_) => {
|
||||||
let button = ui.input(|i| {
|
let button = ui.input(|i| {
|
||||||
@@ -401,20 +662,23 @@ impl Pane {
|
|||||||
self.mouse_event
|
self.mouse_event
|
||||||
.set_action(mouse::Action::Motion)
|
.set_action(mouse::Action::Motion)
|
||||||
.set_button(button);
|
.set_button(button);
|
||||||
self.mouse_encoder
|
input::append_encoded(&mut data, |buffer| {
|
||||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
self.mouse_encoder.encode(&self.mouse_event, buffer)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
egui::Event::MouseWheel { delta, .. } => {
|
egui::Event::MouseWheel { unit, delta, .. } => {
|
||||||
|
let rows = self.mouse_scroll.wheel(unit, delta.y, cell.y, self.size.1);
|
||||||
self.mouse_event
|
self.mouse_event
|
||||||
.set_action(mouse::Action::Press)
|
.set_action(mouse::Action::Press)
|
||||||
.set_button(Some(if delta.y > 0.0 {
|
.set_button(Some(if rows > 0 {
|
||||||
mouse::Button::Four
|
mouse::Button::Four
|
||||||
} else {
|
} else {
|
||||||
mouse::Button::Five
|
mouse::Button::Five
|
||||||
}));
|
}));
|
||||||
for _ in 0..3 {
|
for _ in 0..rows.unsigned_abs() {
|
||||||
self.mouse_encoder
|
input::append_encoded(&mut data, |buffer| {
|
||||||
.encode_to_vec(&self.mouse_event, &mut data)?;
|
self.mouse_encoder.encode(&self.mouse_event, buffer)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -447,10 +711,9 @@ impl Pane {
|
|||||||
}
|
}
|
||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
let scroll = ui.input(|i| i.raw_scroll_delta.y);
|
let scroll = ui.input(|i| i.raw_scroll_delta.y);
|
||||||
if scroll.abs() >= 1.0 {
|
let rows = self.scrollback_scroll.rows(scroll / cell.y);
|
||||||
self.terminal.scroll_viewport(ScrollViewport::Delta(
|
if rows != 0 {
|
||||||
-(scroll / cell.y).round() as isize,
|
self.terminal.scroll_viewport(ScrollViewport::Delta(-rows));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-15
@@ -9,12 +9,14 @@ use libghostty_vt::{
|
|||||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||||
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
||||||
screen::CellWide,
|
screen::CellWide,
|
||||||
style::{RgbColor, Underline},
|
style::{RgbColor, Style, Underline},
|
||||||
|
terminal::Mode,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
io::Cursor,
|
io::Cursor,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's
|
/// Where a pane's terminal is drawn this frame, and the colors that are slerm's
|
||||||
@@ -143,6 +145,23 @@ struct CachedRow {
|
|||||||
backgrounds: Vec<egui::Shape>,
|
backgrounds: Vec<egui::Shape>,
|
||||||
foregrounds: Vec<egui::Shape>,
|
foregrounds: Vec<egui::Shape>,
|
||||||
selection: Option<(u16, u16)>,
|
selection: Option<(u16, u16)>,
|
||||||
|
cells: Vec<CachedCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
struct CellAppearance {
|
||||||
|
text: String,
|
||||||
|
style: Style,
|
||||||
|
fg: Color32,
|
||||||
|
bg: Color32,
|
||||||
|
background: bool,
|
||||||
|
wide: CellWide,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachedCell {
|
||||||
|
appearance: CellAppearance,
|
||||||
|
backgrounds: std::ops::Range<usize>,
|
||||||
|
foregrounds: std::ops::Range<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
|
fn layout_glyph(painter: &Painter, text: String, font: &FontId, height: f32) -> Arc<egui::Galley> {
|
||||||
@@ -205,8 +224,12 @@ pub struct Renderer {
|
|||||||
cached_selection: (Color32, Color32),
|
cached_selection: (Color32, Color32),
|
||||||
cached_cell: egui::Vec2,
|
cached_cell: egui::Vec2,
|
||||||
cached_cols: u16,
|
cached_cols: u16,
|
||||||
|
frame: Vec<egui::epaint::ClippedShape>,
|
||||||
|
sync_started: Option<Instant>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
rebuilt_rows: usize,
|
rebuilt_rows: usize,
|
||||||
|
#[cfg(test)]
|
||||||
|
rebuilt_cells: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Renderer {
|
impl Renderer {
|
||||||
@@ -225,8 +248,12 @@ impl Renderer {
|
|||||||
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
|
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
|
||||||
cached_cell: egui::Vec2::ZERO,
|
cached_cell: egui::Vec2::ZERO,
|
||||||
cached_cols: 0,
|
cached_cols: 0,
|
||||||
|
frame: Vec::new(),
|
||||||
|
sync_started: None,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
rebuilt_rows: 0,
|
rebuilt_rows: 0,
|
||||||
|
#[cfg(test)]
|
||||||
|
rebuilt_cells: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,9 +283,26 @@ impl Renderer {
|
|||||||
if fonts_changed {
|
if fonts_changed {
|
||||||
self.row_cache.clear();
|
self.row_cache.clear();
|
||||||
self.glyphs.clear();
|
self.glyphs.clear();
|
||||||
|
self.frame.clear();
|
||||||
}
|
}
|
||||||
self.font_marker = Some(marker);
|
self.font_marker = Some(marker);
|
||||||
self.cached_cell = cell;
|
self.cached_cell = cell;
|
||||||
|
// Editors may clear and redraw in separate PTY reads. Keep presenting
|
||||||
|
// the last complete frame until DEC synchronized output ends. Bound
|
||||||
|
// the wait so a crashed application cannot leave the display frozen.
|
||||||
|
if term.mode(Mode::SYNC_OUTPUT)? {
|
||||||
|
let elapsed = self.sync_started.get_or_insert_with(Instant::now).elapsed();
|
||||||
|
let timeout = Duration::from_secs(1);
|
||||||
|
if elapsed < timeout && !self.frame.is_empty() {
|
||||||
|
painter.ctx().request_repaint_after(timeout - elapsed);
|
||||||
|
self.present(&painter, rect);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.sync_started = None;
|
||||||
|
}
|
||||||
|
let screen_rect = rect;
|
||||||
|
let rect = Rect::from_min_size(egui::Pos2::ZERO, rect.size());
|
||||||
let style_fonts = painter.ctx().fonts(|fonts| {
|
let style_fonts = painter.ctx().fonts(|fonts| {
|
||||||
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
|
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
|
||||||
let family = crate::fonts::family(bold, italic);
|
let family = crate::fonts::family(bold, italic);
|
||||||
@@ -304,7 +348,8 @@ impl Renderer {
|
|||||||
self.cached_colors = Some(colors.clone());
|
self.cached_colors = Some(colors.clone());
|
||||||
self.cached_selection = selected_colors;
|
self.cached_selection = selected_colors;
|
||||||
self.cached_cols = cols;
|
self.cached_cols = cols;
|
||||||
painter.rect_filled(rect, 0, color(colors.background));
|
let mut shapes = vec![egui::Shape::rect_filled(rect, 0, color(colors.background))];
|
||||||
|
let mut cursor_shapes = Vec::new();
|
||||||
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;
|
||||||
@@ -343,18 +388,43 @@ impl Renderer {
|
|||||||
if selected {
|
if selected {
|
||||||
(bg, fg) = selected_colors;
|
(bg, fg) = selected_colors;
|
||||||
}
|
}
|
||||||
if explicit_bg.is_some() || style.inverse || selected {
|
|
||||||
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
|
|
||||||
}
|
|
||||||
if style.faint {
|
if style.faint {
|
||||||
fg = fg.gamma_multiply(0.6);
|
fg = fg.gamma_multiply(0.6);
|
||||||
}
|
}
|
||||||
let wide = c.raw_cell()?.wide()?;
|
let wide = c.raw_cell()?.wide()?;
|
||||||
|
text.clear();
|
||||||
|
c.graphemes_utf8(&mut text)?;
|
||||||
|
let appearance = CellAppearance {
|
||||||
|
text: text.clone(),
|
||||||
|
style,
|
||||||
|
fg,
|
||||||
|
bg,
|
||||||
|
background: explicit_bg.is_some() || style.inverse || selected,
|
||||||
|
wide,
|
||||||
|
};
|
||||||
|
// Dirty flags are row-granular. Compare cells so edits in one
|
||||||
|
// split don't rebuild its unchanged neighbor on the same row.
|
||||||
|
let background_start = backgrounds.len();
|
||||||
|
let foreground_start = foregrounds.len();
|
||||||
|
if let Some(old) = self.row_cache[y].cells.get(x)
|
||||||
|
&& old.appearance == appearance
|
||||||
|
{
|
||||||
|
backgrounds
|
||||||
|
.extend_from_slice(&self.row_cache[y].backgrounds[old.backgrounds.clone()]);
|
||||||
|
foregrounds
|
||||||
|
.extend_from_slice(&self.row_cache[y].foregrounds[old.foregrounds.clone()]);
|
||||||
|
} else {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
self.rebuilt_cells += 1;
|
||||||
|
}
|
||||||
|
if appearance.background {
|
||||||
|
backgrounds.push(egui::Shape::rect_filled(cell_rect, 0, bg));
|
||||||
|
}
|
||||||
if !style.invisible
|
if !style.invisible
|
||||||
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
|
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
|
||||||
&& c.graphemes_len()? > 0
|
&& c.graphemes_len()? > 0
|
||||||
{
|
{
|
||||||
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 let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
||||||
foregrounds.push(separator);
|
foregrounds.push(separator);
|
||||||
@@ -409,6 +479,12 @@ impl Renderer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
cached.cells.push(CachedCell {
|
||||||
|
appearance,
|
||||||
|
backgrounds: background_start..backgrounds.len(),
|
||||||
|
foregrounds: foreground_start..foregrounds.len(),
|
||||||
|
});
|
||||||
x += 1;
|
x += 1;
|
||||||
}
|
}
|
||||||
self.row_cache[y] = cached;
|
self.row_cache[y] = cached;
|
||||||
@@ -419,7 +495,7 @@ 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(
|
shapes.extend(
|
||||||
self.row_cache
|
self.row_cache
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|row| row.backgrounds.iter())
|
.flat_map(|row| row.backgrounds.iter())
|
||||||
@@ -431,7 +507,7 @@ impl Renderer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if layer == Layer::AboveText {
|
if layer == Layer::AboveText {
|
||||||
painter.extend(
|
shapes.extend(
|
||||||
self.row_cache
|
self.row_cache
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|row| row.foregrounds.iter())
|
.flat_map(|row| row.foregrounds.iter())
|
||||||
@@ -451,6 +527,7 @@ impl Renderer {
|
|||||||
&mut self.placements,
|
&mut self.placements,
|
||||||
&mut self.textures,
|
&mut self.textures,
|
||||||
&mut seen,
|
&mut seen,
|
||||||
|
&mut shapes,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
self.textures.retain(|id, _| seen.contains(id));
|
self.textures.retain(|id, _| seen.contains(id));
|
||||||
@@ -465,25 +542,74 @@ impl Renderer {
|
|||||||
match snapshot.cursor_visual_style()? {
|
match snapshot.cursor_visual_style()? {
|
||||||
CursorVisualStyle::Bar if active => {
|
CursorVisualStyle::Bar if active => {
|
||||||
cursor_rect.max.x = cursor_rect.min.x + 2.0;
|
cursor_rect.max.x = cursor_rect.min.x + 2.0;
|
||||||
painter.rect_filled(cursor_rect, 0, c);
|
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
|
||||||
}
|
}
|
||||||
CursorVisualStyle::Underline if active => {
|
CursorVisualStyle::Underline if active => {
|
||||||
cursor_rect.min.y = cursor_rect.max.y - 2.0;
|
cursor_rect.min.y = cursor_rect.max.y - 2.0;
|
||||||
painter.rect_filled(cursor_rect, 0, c);
|
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
|
||||||
|
}
|
||||||
|
CursorVisualStyle::Block if active => {
|
||||||
|
shapes.push(egui::Shape::rect_filled(cursor_rect, 0, c));
|
||||||
|
// Redraw the covered glyph in the background color so an
|
||||||
|
// application's solid block cursor leaves text readable.
|
||||||
|
let ink = color(colors.background);
|
||||||
|
cursor_shapes.extend(
|
||||||
|
self.row_cache[cursor.y as usize]
|
||||||
|
.foregrounds
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut shape| {
|
||||||
|
shape.translate(rect.min.to_vec2());
|
||||||
|
egui::epaint::shape_transform::adjust_colors(
|
||||||
|
&mut shape,
|
||||||
|
move |color| {
|
||||||
|
if *color != Color32::TRANSPARENT {
|
||||||
|
*color = ink;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
egui::epaint::ClippedShape {
|
||||||
|
clip_rect: cursor_rect.intersect(rect),
|
||||||
|
shape,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
painter.rect_stroke(
|
shapes.push(egui::Shape::rect_stroke(
|
||||||
cursor_rect,
|
cursor_rect,
|
||||||
0,
|
theme::CURSOR_CORNER_RADIUS,
|
||||||
Stroke::new(if active { 1.5_f32 } else { 0.75_f32 }, c),
|
Stroke::new(theme::BORDER_WIDTH, c),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot.set_dirty(Dirty::Clean)?;
|
snapshot.set_dirty(Dirty::Clean)?;
|
||||||
|
self.frame = shapes
|
||||||
|
.into_iter()
|
||||||
|
.map(|shape| egui::epaint::ClippedShape {
|
||||||
|
clip_rect: rect,
|
||||||
|
shape,
|
||||||
|
})
|
||||||
|
.chain(cursor_shapes)
|
||||||
|
.collect();
|
||||||
|
self.present(&painter, screen_rect);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn present(&self, painter: &Painter, rect: Rect) {
|
||||||
|
for cached in &self.frame {
|
||||||
|
let mut shape = cached.shape.clone();
|
||||||
|
shape.translate(rect.min.to_vec2());
|
||||||
|
let clip = cached
|
||||||
|
.clip_rect
|
||||||
|
.translate(rect.min.to_vec2())
|
||||||
|
.intersect(rect)
|
||||||
|
.intersect(painter.clip_rect());
|
||||||
|
painter.with_clip_rect(clip).add(shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -496,6 +622,7 @@ fn paint_images(
|
|||||||
iterator: &mut PlacementIterator<'_>,
|
iterator: &mut PlacementIterator<'_>,
|
||||||
textures: &mut HashMap<u32, (u64, TextureHandle)>,
|
textures: &mut HashMap<u32, (u64, TextureHandle)>,
|
||||||
seen: &mut HashSet<u32>,
|
seen: &mut HashSet<u32>,
|
||||||
|
shapes: &mut Vec<egui::Shape>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let graphics = term.kitty_graphics()?;
|
let graphics = term.kitty_graphics()?;
|
||||||
let mut placements = iterator.update(&graphics)?;
|
let mut placements = iterator.update(&graphics)?;
|
||||||
@@ -570,7 +697,12 @@ fn paint_images(
|
|||||||
}
|
}
|
||||||
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
|
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
|
||||||
for (_, id, _, dest, uv) in images {
|
for (_, id, _, dest, uv) in images {
|
||||||
painter.image(textures[&id].1.id(), dest, uv, Color32::WHITE);
|
shapes.push(egui::Shape::image(
|
||||||
|
textures[&id].1.id(),
|
||||||
|
dest,
|
||||||
|
uv,
|
||||||
|
Color32::WHITE,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -681,6 +813,69 @@ mod tests {
|
|||||||
.shapes
|
.shapes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_one_editor_split_reuses_unchanged_cells() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut term =
|
||||||
|
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
term.resize(20, 5, 8, 16).unwrap();
|
||||||
|
term.vt_write(b"\x1b[?25l");
|
||||||
|
for row in 1..=5 {
|
||||||
|
term.vt_write(format!("\x1b[{row};1Hleft {row} right {row}").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();
|
||||||
|
frame(&ctx, &mut renderer, &term, rect, &font);
|
||||||
|
assert_eq!(renderer.rebuilt_cells, 100);
|
||||||
|
|
||||||
|
// Simulate an editor repainting entire rows while only its left split changes.
|
||||||
|
for row in 1..=5 {
|
||||||
|
term.vt_write(format!("\x1b[{row};1Hleft {} right {row}", row + 1).as_bytes());
|
||||||
|
}
|
||||||
|
let actual = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||||
|
assert_eq!(renderer.rebuilt_cells, 105, "only five digits changed");
|
||||||
|
assert_eq!(
|
||||||
|
actual,
|
||||||
|
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||||
|
);
|
||||||
|
// Rewriting identical text should reuse every cell, even on dirty rows.
|
||||||
|
term.vt_write(b"\x1b[1;1Hleft 2 right 1");
|
||||||
|
frame(&ctx, &mut renderer, &term, rect, &font);
|
||||||
|
assert_eq!(renderer.rebuilt_cells, 105);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synchronized_updates_keep_the_last_complete_frame() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut term =
|
||||||
|
crate::pane::terminal(&crate::config::Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
term.resize(20, 5, 8, 16).unwrap();
|
||||||
|
term.vt_write(b"\x1b[?25lleft buffer\r\nright buffer");
|
||||||
|
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);
|
||||||
|
let cells = renderer.rebuilt_cells;
|
||||||
|
term.vt_write(b"\x1b[?2026h\x1b[2J\x1b[Hpartial");
|
||||||
|
assert!(term.mode(Mode::SYNC_OUTPUT).unwrap());
|
||||||
|
assert_eq!(initial, frame(&ctx, &mut renderer, &term, rect, &font));
|
||||||
|
assert_eq!(renderer.rebuilt_cells, cells);
|
||||||
|
term.vt_write(b"\rcomplete\r\nright buffer\x1b[?2026l");
|
||||||
|
let completed = frame(&ctx, &mut renderer, &term, rect, &font);
|
||||||
|
assert_ne!(initial, completed);
|
||||||
|
assert_eq!(
|
||||||
|
completed,
|
||||||
|
frame(&ctx, &mut Renderer::new().unwrap(), &term, rect, &font)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A missing end marker must not freeze the terminal indefinitely.
|
||||||
|
term.vt_write(b"\x1b[?2026h\x1b[Htimeout");
|
||||||
|
assert_eq!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
|
||||||
|
renderer.sync_started = Some(Instant::now() - Duration::from_secs(2));
|
||||||
|
assert_ne!(completed, frame(&ctx, &mut renderer, &term, rect, &font));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cached_rows_match_fresh_rendering_after_terminal_changes() {
|
fn cached_rows_match_fresh_rendering_after_terminal_changes() {
|
||||||
use libghostty_vt::{
|
use libghostty_vt::{
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_defaults_to_hollow_and_respects_application_styles() {
|
||||||
|
use libghostty_vt::{RenderState, render::CursorVisualStyle};
|
||||||
|
|
||||||
|
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
let mut state = RenderState::new().unwrap();
|
||||||
|
let snapshot = state.update(&term).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.cursor_visual_style().unwrap(),
|
||||||
|
CursorVisualStyle::BlockHollow
|
||||||
|
);
|
||||||
|
for (sequence, expected) in [
|
||||||
|
(b"\x1b[2 q", CursorVisualStyle::Block),
|
||||||
|
(b"\x1b[4 q", CursorVisualStyle::Underline),
|
||||||
|
(b"\x1b[6 q", CursorVisualStyle::Bar),
|
||||||
|
(b"\x1b[0 q", CursorVisualStyle::BlockHollow),
|
||||||
|
] {
|
||||||
|
term.vt_write(sequence);
|
||||||
|
let snapshot = state.update(&term).unwrap();
|
||||||
|
assert_eq!(snapshot.cursor_visual_style().unwrap(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_preserves_unicode_colors_and_application_input_modes() {
|
fn terminal_preserves_unicode_colors_and_application_input_modes() {
|
||||||
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
||||||
@@ -57,6 +80,54 @@ fn terminal_preserves_unicode_colors_and_application_input_modes() {
|
|||||||
assert!(plain(&term).contains("hello red"));
|
assert!(plain(&term).contains("hello red"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_encoding_grows_buffers_without_losing_events() {
|
||||||
|
let term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
let mut events = vec![Event::Text("a".into()), Event::Text("123456789".into())];
|
||||||
|
let long_text = "界".repeat(200);
|
||||||
|
events.push(Event::Text(long_text.clone()));
|
||||||
|
for _ in 0..100 {
|
||||||
|
events.push(key(Key::ArrowUp, Modifiers::NONE));
|
||||||
|
}
|
||||||
|
let expected = format!("a123456789{long_text}{}", "\x1b[A".repeat(100));
|
||||||
|
assert_eq!(input::encode(&term, &events).unwrap(), expected.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_encoding_appends_when_destination_has_insufficient_spare_capacity() {
|
||||||
|
use libghostty_vt::mouse;
|
||||||
|
|
||||||
|
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
term.vt_write(b"\x1b[?1000h\x1b[?1006h");
|
||||||
|
let mut encoder = mouse::Encoder::new().unwrap();
|
||||||
|
encoder
|
||||||
|
.set_options_from_terminal(&term)
|
||||||
|
.set_size(mouse::EncoderSize {
|
||||||
|
screen_width: 800,
|
||||||
|
screen_height: 600,
|
||||||
|
cell_width: 8,
|
||||||
|
cell_height: 16,
|
||||||
|
padding_top: 0,
|
||||||
|
padding_bottom: 0,
|
||||||
|
padding_left: 0,
|
||||||
|
padding_right: 0,
|
||||||
|
});
|
||||||
|
let mut event = mouse::Event::new().unwrap();
|
||||||
|
event
|
||||||
|
.set_action(mouse::Action::Press)
|
||||||
|
.set_button(Some(mouse::Button::Left))
|
||||||
|
.set_position(mouse::Position { x: 0.0, y: 0.0 });
|
||||||
|
let mut out = Vec::with_capacity(16);
|
||||||
|
out.resize(9, b'x');
|
||||||
|
for _ in 0..100 {
|
||||||
|
input::append_encoded(&mut out, |buffer| encoder.encode(&event, buffer)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
format!("xxxxxxxxx{}", "\x1b[<0;1;1M".repeat(100)).as_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queries_return_to_the_pty_and_alternate_screen_restores() {
|
fn queries_return_to_the_pty_and_alternate_screen_restores() {
|
||||||
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
let mut term = pane::terminal(&Config::default(), &theme::DEFAULT).unwrap();
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ use libghostty_vt::style::{Palette, PaletteIndex, RgbColor};
|
|||||||
/// integers rather than anything that needs allocating or parsing at startup.
|
/// integers rather than anything that needs allocating or parsing at startup.
|
||||||
pub type Rgb = u32;
|
pub type Rgb = u32;
|
||||||
|
|
||||||
|
/// Shared stroke width for pane borders and the default hollow cursor.
|
||||||
|
pub const BORDER_WIDTH: f32 = 1.0;
|
||||||
|
pub const CORNER_RADIUS: u8 = 5;
|
||||||
|
pub const CURSOR_CORNER_RADIUS: u8 = 2;
|
||||||
|
|
||||||
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen
|
/// Every color slerm draws with. A theme covers the terminal grid, the sixteen
|
||||||
/// ANSI colors programs address by index, and the chrome around them, so that
|
/// ANSI colors programs address by index, and the chrome around them, so that
|
||||||
/// picking a light theme does not leave a dark tab bar behind.
|
/// picking a light theme does not leave a dark tab bar behind.
|
||||||
|
|||||||
Reference in New Issue
Block a user