better floating nac
This commit is contained in:
@@ -64,6 +64,7 @@ floating_max_height = 640
|
||||
"Alt+F" = "toggle_floats"
|
||||
"Alt+M" = "new_pane"
|
||||
"Alt+H" = "focus_left"
|
||||
# With floating panes shown, J/K cycle forward/backward through them.
|
||||
"Alt+J" = "focus_down"
|
||||
"Alt+K" = "focus_up"
|
||||
"Alt+L" = "focus_right"
|
||||
|
||||
+93
-5
@@ -152,6 +152,7 @@ pub struct Tab {
|
||||
pub floats: Vec<PaneId>,
|
||||
pub active: PaneId,
|
||||
pub floats_visible: bool,
|
||||
last_active_float: Option<PaneId>,
|
||||
pub zoomed: bool,
|
||||
}
|
||||
|
||||
@@ -163,6 +164,7 @@ impl Tab {
|
||||
floats: vec![],
|
||||
active: pane,
|
||||
floats_visible: false,
|
||||
last_active_float: None,
|
||||
zoomed: false,
|
||||
}
|
||||
}
|
||||
@@ -177,6 +179,9 @@ impl Tab {
|
||||
pub fn detach(&mut self, id: PaneId) {
|
||||
self.root = self.root.take().and_then(|r| r.remove(id));
|
||||
self.floats.retain(|p| *p != id);
|
||||
if self.last_active_float == Some(id) {
|
||||
self.last_active_float = None;
|
||||
}
|
||||
if self.floats.is_empty() {
|
||||
self.floats_visible = false;
|
||||
}
|
||||
@@ -201,17 +206,29 @@ impl Tab {
|
||||
self.floats_visible = !self.floats_visible;
|
||||
self.zoomed = false;
|
||||
if self.floats_visible {
|
||||
if let Some(id) = self.floats.last() {
|
||||
self.active = *id;
|
||||
}
|
||||
} else if self.floats.contains(&self.active)
|
||||
&& let Some(root) = &self.root
|
||||
if let Some(id) = self
|
||||
.last_active_float
|
||||
.filter(|id| self.floats.contains(id))
|
||||
.or_else(|| self.floats.last().copied())
|
||||
{
|
||||
self.active = id;
|
||||
}
|
||||
} else if self.floats.contains(&self.active) {
|
||||
self.last_active_float = Some(self.active);
|
||||
if let Some(root) = &self.root {
|
||||
self.active = root.first();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
.find(|(id, _)| *id == self.active && self.is_visible(*id))
|
||||
@@ -322,6 +339,51 @@ mod tests {
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
|
||||
let mut tab = Tab::new(1, 1);
|
||||
@@ -375,6 +437,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]
|
||||
fn visible_floats_constrain_directional_and_cyclic_navigation() {
|
||||
let mut tab = Tab::new(1, 1);
|
||||
@@ -395,6 +481,8 @@ mod tests {
|
||||
(Direction::Right, 4),
|
||||
(Direction::Down, 5),
|
||||
(Direction::Left, 6),
|
||||
(Direction::Up, 5),
|
||||
(Direction::Up, 4),
|
||||
(Direction::Up, 3),
|
||||
] {
|
||||
tab.focus_direction(direction, &rects);
|
||||
|
||||
+187
-18
@@ -9,12 +9,14 @@ use libghostty_vt::{
|
||||
kitty::graphics::{DecodePng, DecodedImage, ImageFormat, Layer, PlacementIterator},
|
||||
render::{CellIterator, Colors, CursorVisualStyle, Dirty, RowIterator},
|
||||
screen::CellWide,
|
||||
style::{RgbColor, Underline},
|
||||
style::{RgbColor, Style, Underline},
|
||||
terminal::Mode,
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io::Cursor,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// 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>,
|
||||
foregrounds: Vec<egui::Shape>,
|
||||
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> {
|
||||
@@ -205,8 +224,12 @@ pub struct Renderer {
|
||||
cached_selection: (Color32, Color32),
|
||||
cached_cell: egui::Vec2,
|
||||
cached_cols: u16,
|
||||
frame: Vec<egui::epaint::ClippedShape>,
|
||||
sync_started: Option<Instant>,
|
||||
#[cfg(test)]
|
||||
rebuilt_rows: usize,
|
||||
#[cfg(test)]
|
||||
rebuilt_cells: usize,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -225,8 +248,12 @@ impl Renderer {
|
||||
cached_selection: (Color32::PLACEHOLDER, Color32::PLACEHOLDER),
|
||||
cached_cell: egui::Vec2::ZERO,
|
||||
cached_cols: 0,
|
||||
frame: Vec::new(),
|
||||
sync_started: None,
|
||||
#[cfg(test)]
|
||||
rebuilt_rows: 0,
|
||||
#[cfg(test)]
|
||||
rebuilt_cells: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,9 +283,26 @@ impl Renderer {
|
||||
if fonts_changed {
|
||||
self.row_cache.clear();
|
||||
self.glyphs.clear();
|
||||
self.frame.clear();
|
||||
}
|
||||
self.font_marker = Some(marker);
|
||||
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| {
|
||||
[(false, false), (true, false), (false, true), (true, true)].map(|(bold, italic)| {
|
||||
let family = crate::fonts::family(bold, italic);
|
||||
@@ -304,7 +348,8 @@ impl Renderer {
|
||||
self.cached_colors = Some(colors.clone());
|
||||
self.cached_selection = selected_colors;
|
||||
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 text = String::new();
|
||||
let mut y = 0;
|
||||
@@ -343,18 +388,43 @@ impl Renderer {
|
||||
if selected {
|
||||
(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 {
|
||||
fg = fg.gamma_multiply(0.6);
|
||||
}
|
||||
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
|
||||
&& !matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail)
|
||||
&& c.graphemes_len()? > 0
|
||||
{
|
||||
c.graphemes_utf8(&mut text)?;
|
||||
// Placeholder cells are reserved by the Kitty protocol, not printable text.
|
||||
if let Some(separator) = powerline_shape(&text, cell_rect, fg) {
|
||||
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;
|
||||
}
|
||||
self.row_cache[y] = cached;
|
||||
@@ -419,7 +495,7 @@ impl Renderer {
|
||||
let mut seen = HashSet::new();
|
||||
for layer in [Layer::BelowBg, Layer::BelowText, Layer::AboveText] {
|
||||
if layer == Layer::BelowText {
|
||||
painter.extend(
|
||||
shapes.extend(
|
||||
self.row_cache
|
||||
.iter()
|
||||
.flat_map(|row| row.backgrounds.iter())
|
||||
@@ -431,7 +507,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
if layer == Layer::AboveText {
|
||||
painter.extend(
|
||||
shapes.extend(
|
||||
self.row_cache
|
||||
.iter()
|
||||
.flat_map(|row| row.foregrounds.iter())
|
||||
@@ -451,6 +527,7 @@ impl Renderer {
|
||||
&mut self.placements,
|
||||
&mut self.textures,
|
||||
&mut seen,
|
||||
&mut shapes,
|
||||
)?;
|
||||
}
|
||||
self.textures.retain(|id, _| seen.contains(id));
|
||||
@@ -465,20 +542,18 @@ impl Renderer {
|
||||
match snapshot.cursor_visual_style()? {
|
||||
CursorVisualStyle::Bar if active => {
|
||||
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 => {
|
||||
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 => {
|
||||
painter.rect_filled(cursor_rect, 0, c);
|
||||
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);
|
||||
let cursor_painter =
|
||||
painter.with_clip_rect(cursor_rect.intersect(painter.clip_rect()));
|
||||
cursor_painter.extend(
|
||||
cursor_shapes.extend(
|
||||
self.row_cache[cursor.y as usize]
|
||||
.foregrounds
|
||||
.iter()
|
||||
@@ -493,23 +568,48 @@ impl Renderer {
|
||||
}
|
||||
},
|
||||
);
|
||||
shape
|
||||
egui::epaint::ClippedShape {
|
||||
clip_rect: cursor_rect.intersect(rect),
|
||||
shape,
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
painter.rect_stroke(
|
||||
shapes.push(egui::Shape::rect_stroke(
|
||||
cursor_rect,
|
||||
theme::CURSOR_CORNER_RADIUS,
|
||||
Stroke::new(theme::BORDER_WIDTH, c),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -522,6 +622,7 @@ fn paint_images(
|
||||
iterator: &mut PlacementIterator<'_>,
|
||||
textures: &mut HashMap<u32, (u64, TextureHandle)>,
|
||||
seen: &mut HashSet<u32>,
|
||||
shapes: &mut Vec<egui::Shape>,
|
||||
) -> Result<()> {
|
||||
let graphics = term.kitty_graphics()?;
|
||||
let mut placements = iterator.update(&graphics)?;
|
||||
@@ -596,7 +697,12 @@ fn paint_images(
|
||||
}
|
||||
images.sort_by_key(|(z, id, pid, _, _)| (*z, *id, *pid));
|
||||
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(())
|
||||
}
|
||||
@@ -707,6 +813,69 @@ mod tests {
|
||||
.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]
|
||||
fn cached_rows_match_fresh_rendering_after_terminal_changes() {
|
||||
use libghostty_vt::{
|
||||
|
||||
Reference in New Issue
Block a user