fix rendering bug

This commit is contained in:
2026-06-04 14:05:45 +02:00
parent 7f4249dde9
commit 3164873a66
5 changed files with 487 additions and 466 deletions
-3
View File
@@ -387,7 +387,6 @@ impl Config {
};
if !config_path.exists() {
log::info!("No config file found at {:?}, creating with defaults", config_path);
let default_config = Self::default();
if let Err(e) = default_config.save() {
log::warn!("Failed to write default config: {}", e);
@@ -398,7 +397,6 @@ impl Config {
match fs::read_to_string(&config_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(config) => {
log::info!("Loaded config from {:?}", config_path);
config
}
Err(e) => {
@@ -431,7 +429,6 @@ impl Config {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
fs::write(&config_path, json)?;
log::info!("Saved config to {:?}", config_path);
Ok(())
}
}
+2 -19
View File
@@ -214,19 +214,6 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
// Try to use fontconfig to find the font family
if let Some(family) = font_family {
let paths = find_font_family_variants(family);
log::info!("Font family '{}' resolved to:", family);
for (i, path) in paths.iter().enumerate() {
let style = match i {
0 => "Regular",
1 => "Bold",
2 => "Italic",
3 => "BoldItalic",
_ => "Unknown",
};
if let Some(p) = path {
log::info!(" {}: {:?}", style, p);
}
}
// Load the regular font (required)
if let Some(regular_path) = &paths[0] {
@@ -277,11 +264,7 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
load_font_variant(std::path::Path::new(bold_italic)),
];
log::info!("Loaded font from fallback paths:");
log::info!(" Regular: {}", regular);
if variants[1].is_some() { log::info!(" Bold: {}", bold); }
if variants[2].is_some() { log::info!(" Italic: {}", italic); }
if variants[3].is_some() { log::info!(" BoldItalic: {}", bold_italic); }
return (font_data, primary_font, variants);
}
@@ -293,7 +276,7 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
let primary_font = regular_variant.clone_font();
let font_data = regular_variant.clone_data();
let variants: [Option<FontVariant>; 4] = [Some(regular_variant), None, None, None];
log::info!("Loaded NotoSansMono as fallback");
return (font_data, primary_font, variants);
}
+323 -156
View File
@@ -219,6 +219,8 @@ impl Pane {
/// Geometry of a pane in pixels.
#[derive(Debug, Clone, Copy)]
struct PaneGeometry {
/// Pane ID.
pane_id: PaneId,
/// Left edge in pixels.
x: f32,
/// Top edge in pixels.
@@ -260,6 +262,7 @@ impl SplitNode {
SplitNode::Leaf {
pane_id,
geometry: PaneGeometry {
pane_id,
x: 0.0,
y: 0.0,
width: 0.0,
@@ -295,13 +298,14 @@ impl SplitNode {
_border_width: f32,
) -> (f32, f32) {
match self {
SplitNode::Leaf { geometry, .. } => {
SplitNode::Leaf { pane_id, geometry } => {
// Calculate how many cells fit
let cols = (width / cell_width).floor() as usize;
let rows = (height / cell_height).floor() as usize;
// Store the full allocated dimensions (not just cell-aligned)
// This ensures edge glow and pane dimming cover the full pane area
*geometry = PaneGeometry {
pane_id: *pane_id,
x,
y,
width, // Full allocated width
@@ -436,6 +440,30 @@ impl SplitNode {
}
}
/// Find the geometry of the pane that contains the given pixel position.
fn find_pane_at_pixel(&self, x: f64, y: f64) -> Option<PaneGeometry> {
match self {
SplitNode::Leaf { geometry, .. } => {
if x >= geometry.x as f64
&& x < (geometry.x + geometry.width) as f64
&& y >= geometry.y as f64
&& y < (geometry.y + geometry.height) as f64
{
Some(*geometry)
} else {
None
}
}
SplitNode::Split {
first, second, ..
} => {
first
.find_pane_at_pixel(x, y)
.or_else(|| second.find_pane_at_pixel(x, y))
}
}
}
/// Find a neighbor pane in the given direction.
/// Returns the pane ID of the neighbor, if any.
fn find_neighbor(
@@ -833,6 +861,17 @@ impl Tab {
fn child_exited(&mut self) -> bool {
self.check_exited_panes()
}
/// Focus a specific pane by ID. Returns true if focus changed.
fn focus_pane_by_id(&mut self, pane_id: PaneId) -> bool {
if self.panes.contains_key(&pane_id) {
let old = self.active_pane;
self.active_pane = pane_id;
old != pane_id
} else {
false
}
}
}
/// PID file location for single-instance support.
@@ -854,7 +893,6 @@ fn signal_existing_instance() -> bool {
if alive {
// Send SIGUSR1 to show window
log::info!("Signaling existing instance (PID {})", pid);
unsafe { libc::kill(pid, libc::SIGUSR1) };
return true;
} else {
@@ -1416,13 +1454,8 @@ struct App {
impl App {
fn new() -> Self {
let config = Config::load();
log::info!("Config: font_size={}", config.font_size);
let action_map = config.keybindings.build_action_map();
log::info!("Action map built with {} bindings:", action_map.len());
for (key, action) in &action_map {
log::info!(" {:?} => {:?}", key, action);
}
Self {
window: None,
@@ -1463,7 +1496,6 @@ impl App {
/// Reload configuration from disk and apply changes.
fn reload_config(&mut self) {
log::info!("Reloading configuration...");
let new_config = Config::load();
// Check what changed and apply updates
@@ -1475,6 +1507,7 @@ impl App {
> 0.01;
let tab_bar_changed =
new_config.tab_bar_position != self.config.tab_bar_position;
let new_font_size = new_config.font_size;
// Update the config
self.config = new_config;
@@ -1486,23 +1519,14 @@ impl App {
if let Some(renderer) = &mut self.renderer {
if opacity_changed {
renderer.set_background_opacity(self.config.background_opacity);
log::info!(
"Updated background opacity to {}",
self.config.background_opacity
);
}
if tab_bar_changed {
renderer.set_tab_bar_position(self.config.tab_bar_position);
log::info!(
"Updated tab bar position to {:?}",
self.config.tab_bar_position
);
}
if font_size_changed {
renderer.set_font_size(self.config.font_size);
log::info!("Updated font size to {}", self.config.font_size);
self.config.font_size = new_font_size;
// Font size change requires resize to recalculate cell dimensions
self.resize_all_panes();
}
@@ -1510,8 +1534,6 @@ impl App {
// Request redraw to apply visual changes
self.request_redraw();
log::info!("Configuration reloaded successfully");
}
/// Request a window redraw if window is available.
@@ -1525,8 +1547,6 @@ impl App {
/// Create a new tab and start its I/O thread.
/// Returns the index of the new tab.
fn create_tab(&mut self, cols: usize, rows: usize) -> Option<usize> {
log::info!("Creating new tab with {}x{} terminal", cols, rows);
match Tab::new(cols, rows, self.config.scrollback_lines) {
Ok(tab) => {
let tab_idx = self.tabs.len();
@@ -1539,11 +1559,6 @@ impl App {
self.tabs.push(tab);
self.active_tab = tab_idx;
log::info!(
"Tab {} created (total: {})",
tab_idx,
self.tabs.len()
);
Some(tab_idx)
}
Err(e) => {
@@ -1749,17 +1764,6 @@ impl App {
}
}
#[cfg(feature = "render_timing")]
{
let elapsed = io_start.elapsed();
log::info!("[IO-{}] Thread exiting: loops={} total_bytes={} elapsed={:?} throughput={:.1} MB/s",
pane_id.0, loop_count, total_bytes_read, elapsed,
total_bytes_read as f64 / elapsed.as_secs_f64() / 1_000_000.0);
}
#[cfg(not(feature = "render_timing"))]
let _ = (io_start, loop_count, total_bytes_read); // silence unused warnings
log::debug!("PTY I/O thread for pane {} exiting", pane_id.0);
})
.expect("Failed to spawn PTY I/O thread");
@@ -1771,8 +1775,6 @@ impl App {
return; // Window already exists
}
log::info!("Creating window");
let mut window_attributes = Window::default_attributes()
.with_title("ZTerm")
.with_inner_size(PhysicalSize::new(800, 600));
@@ -1802,13 +1804,10 @@ impl App {
self.window = Some(window);
self.renderer = Some(renderer);
self.should_create_window = false;
log::info!("Window created: {}x{} cells", cols, rows);
}
/// Destroy the window but keep terminal state.
fn destroy_window(&mut self) {
log::info!("Destroying window (keeping terminal alive)");
self.renderer = None;
self.window = None;
}
@@ -1977,8 +1976,8 @@ impl App {
.saturating_sub(pane.last_scrollback_len)
as isize;
if let Some(ref mut selection) = pane.selection {
selection.start.row -= lines_added;
selection.end.row -= lines_added;
selection.start.row = selection.start.row.saturating_sub(lines_added);
selection.end.row = selection.end.row.saturating_sub(lines_added);
}
pane.last_scrollback_len = scrollback_len;
}
@@ -1990,6 +1989,7 @@ impl App {
let active_tab_idx = self.active_tab;
let fade_duration_ms = self.config.inactive_pane_fade_ms;
let inactive_dim = self.config.inactive_pane_dim;
let has_pending_redraw = self.renderer.as_ref().map(|r| r.has_pending_redraw()).unwrap_or(false);
if let Some(renderer) = &mut self.renderer {
if let Some(tab) = self.tabs.get_mut(active_tab_idx) {
@@ -2019,7 +2019,8 @@ impl App {
.unwrap_or(false)
});
if !has_dirty_content && !self.needs_redraw && self.edge_glows.is_empty() && !fade_in_progress {
let has_selection = tab.panes.values().any(|p| p.selection.is_some());
if !has_dirty_content && !self.needs_redraw && self.edge_glows.is_empty() && !fade_in_progress && !has_selection && !has_pending_redraw {
let image_animation_in_progress = tab.panes.values().any(|p| {
p.terminal.image_storage.has_animations()
});
@@ -2083,14 +2084,9 @@ impl App {
inactive_dim
});
// Convert selection to screen coords for this pane
let selection = if is_active {
pane.selection.as_ref().and_then(|sel| {
let selection = pane.selection.as_ref().and_then(|sel| {
sel.to_screen_coords(scroll_offset, geom.rows)
})
} else {
None
};
});
let render_info = PaneRenderInfo {
pane_id: pane_id.0,
@@ -2172,6 +2168,10 @@ impl App {
pane.terminal.clear_dirty_lines();
}
}
// Clear pending redraw and needs_redraw
if let Some(renderer) = &mut self.renderer {
renderer.clear_pending_redraw();
}
// Only clear needs_redraw if no pane fade is still in progress
let any_fade_in_progress = geometries.iter().any(|(pane_id, _)| {
tab.panes
@@ -2182,12 +2182,12 @@ impl App {
})
.unwrap_or(false)
});
if !any_fade_in_progress {
let any_selection = tab.panes.values().any(|p| p.selection.is_some());
if !any_fade_in_progress && !any_selection {
self.needs_redraw = false;
}
}
Err(wgpu::SurfaceError::Lost) => {
log::info!("RENDER LOST: resizing");
renderer.resize(renderer.width, renderer.height);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
@@ -2273,6 +2273,19 @@ impl App {
.unwrap_or(false)
}
fn has_mouse_tracking_for_pane(&self, pane_id: PaneId) -> bool {
self.active_tab()
.and_then(|t| t.panes.get(&pane_id))
.map(|p| p.terminal.mouse_tracking != MouseTrackingMode::None)
.unwrap_or(false)
}
/// Find the pane geometry at a given pixel position in the active tab.
fn pane_at_pixel(&self, x: f64, y: f64) -> Option<PaneGeometry> {
self.active_tab()
.and_then(|t| t.split_root.find_pane_at_pixel(x, y))
}
fn get_mouse_modifiers(&self) -> u8 {
let mod_state = self.modifiers.state();
let mut mods = 0u8;
@@ -2290,27 +2303,30 @@ impl App {
fn send_mouse_event(
&mut self,
pane_id: PaneId,
button: u8,
col: u16,
row: u16,
pressed: bool,
is_motion: bool,
) {
let seq = {
let Some(pane) = self.active_pane() else {
let modifiers = self.get_mouse_modifiers();
let Some(tab) = self.active_tab_mut() else {
return;
};
pane.terminal.encode_mouse(
let Some(pane) = tab.panes.get_mut(&pane_id) else {
return;
};
let seq = pane.terminal.encode_mouse(
button,
col,
row,
pressed,
is_motion,
self.get_mouse_modifiers(),
)
};
modifiers,
);
if !seq.is_empty() {
self.write_to_pty(&seq);
pane.pty.write(&seq);
}
}
@@ -2369,8 +2385,6 @@ impl App {
return false;
};
log::info!("Executing action: {:?}", action);
self.execute_action(action);
true
}
@@ -2384,13 +2398,19 @@ impl App {
self.paste_from_clipboard();
}
Action::NewTab => {
if let Some(renderer) = &self.renderer {
let (cols, rows) = renderer.terminal_size();
let (cols, rows) = if let Some(renderer) = &self.renderer {
renderer.terminal_size()
} else {
return;
};
self.create_tab(cols, rows);
// Resize the new tab to calculate pane geometries
self.resize_all_panes();
self.request_redraw();
self.needs_redraw = true;
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
self.request_redraw();
}
Action::ClosePane => {
self.close_active_pane();
@@ -2477,11 +2497,6 @@ impl App {
self.resize_all_panes();
self.needs_redraw = true;
self.request_redraw();
log::info!(
"Split pane (horizontal={}), new pane {}",
horizontal,
pane_id.0
);
}
}
@@ -2858,20 +2873,18 @@ impl ApplicationHandler<UserEvent> for App {
if self.window.is_none() {
self.create_window(event_loop);
}
#[cfg(feature = "render_timing")]
log::info!("App resumed (window creation): {:?}", start.elapsed());
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::ShowWindow => {
log::info!("Received signal to show window");
if self.window.is_none() {
self.create_window(event_loop);
}
}
UserEvent::Tick => {
log::info!("[MAIN] Tick received");
// Check for fatal render errors from previous frames
if self.render_fatal_error {
log::error!("Fatal render error occurred, exiting");
@@ -3011,16 +3024,6 @@ impl ApplicationHandler<UserEvent> for App {
let _ = proxy.send_event(UserEvent::Tick);
}
}
// Log every tick during benchmark for analysis (only with render_timing feature)
#[cfg(feature = "render_timing")]
{
let tick_time = tick_start.elapsed();
if tick_time.as_millis() > 5 {
log::info!("[TICK] render={:?} total={:?} has_more={} rendered={}",
render_time, tick_time, any_has_more, should_render);
}
}
}
UserEvent::ConfigReloaded => {
self.reload_config();
@@ -3036,7 +3039,7 @@ impl ApplicationHandler<UserEvent> for App {
) {
match event {
WindowEvent::CloseRequested => {
log::info!("Window close requested - hiding window");
self.destroy_window();
// Don't exit - keep running headless
}
@@ -3046,7 +3049,7 @@ impl ApplicationHandler<UserEvent> for App {
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
log::info!("Scale factor changed to {}", scale_factor);
let should_resize = if let Some(renderer) = &mut self.renderer {
renderer.set_scale_factor(scale_factor)
} else {
@@ -3085,23 +3088,45 @@ impl ApplicationHandler<UserEvent> for App {
};
if lines != 0 {
if self.has_mouse_tracking() {
if let Some(renderer) = &self.renderer {
if let Some((col, row)) = renderer.pixel_to_cell(
if let Some(tab) = self.active_tab() {
if let Some(pane_geom) = tab.split_root.find_pane_at_pixel(
self.cursor_position.x,
self.cursor_position.y,
) {
if let Some(renderer) = &self.renderer {
if let Some((col, row)) = renderer.pane_pixel_to_cell(
self.cursor_position.x,
self.cursor_position.y,
pane_geom.x,
pane_geom.y,
pane_geom.width,
pane_geom.height,
pane_geom.cols,
pane_geom.rows,
) {
if self.has_mouse_tracking_for_pane(pane_geom.pane_id) {
let button = if lines > 0 { 64 } else { 65 };
let count = lines.abs().min(3);
let modifiers = self.get_mouse_modifiers();
for _ in 0..count {
self.send_mouse_event(
button, col as u16, row as u16, true,
false,
if let Some(active_tab) = self.active_tab_mut() {
if let Some(pane) = active_tab.panes.get_mut(&pane_geom.pane_id) {
let seq = pane.terminal.encode_mouse(
button, col as u16, row as u16, true, false,
modifiers,
);
if !seq.is_empty() {
pane.pty.write(&seq);
}
}
}
} else if let Some(tab) = self.active_tab_mut() {
}
}
}
}
}
}
if let Some(tab) = self.active_tab_mut() {
// Positive lines = scroll wheel up = go into history (increase offset)
if let Some(pane) = tab.active_pane_mut() {
pane.terminal.scroll(lines);
@@ -3114,33 +3139,86 @@ impl ApplicationHandler<UserEvent> for App {
WindowEvent::CursorMoved { position, .. } => {
self.cursor_position = position;
let is_selecting =
self.active_pane().map(|p| p.is_selecting).unwrap_or(false);
// Find the pane under the mouse for pane-aware coordinate conversion
let pane_geom = if let Some(tab) = self.active_tab() {
tab.split_root.find_pane_at_pixel(position.x, position.y)
} else {
None
};
if is_selecting && self.has_mouse_tracking() {
// Switch focus if dragging on a different pane
if self.mouse_down_pos.is_some() {
if let Some(geom) = &pane_geom {
if let Some(tab) = self.active_tab_mut() {
if tab.active_pane != geom.pane_id {
if tab.focus_pane_by_id(geom.pane_id) {
self.needs_redraw = true;
}
}
}
}
}
// Check is_selecting on both the active pane and the pane under the mouse
let active_is_selecting =
self.active_pane().map(|p| p.is_selecting).unwrap_or(false);
let mouse_pane_is_selecting = pane_geom
.as_ref()
.and_then(|g| self.active_tab().and_then(|t| t.panes.get(&g.pane_id)))
.map(|p| p.is_selecting)
.unwrap_or(false);
let is_selecting = active_is_selecting || mouse_pane_is_selecting;
let mouse_tracking = pane_geom
.as_ref()
.map(|g| self.has_mouse_tracking_for_pane(g.pane_id))
.unwrap_or(false);
if is_selecting && mouse_tracking {
let modifiers = self.get_mouse_modifiers();
// Send mouse drag/motion events to PTY for apps like Neovim
if let Some(renderer) = &self.renderer {
if let Some((col, row)) =
renderer.pixel_to_cell(position.x, position.y)
{
if let Some(geom) = pane_geom {
if let Some((col, row)) = renderer.pane_pixel_to_cell(
position.x, position.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
) {
// Button 0 (left) with motion flag
self.send_mouse_event(
if let Some(active_tab) = self.active_tab_mut() {
if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) {
let seq = pane.terminal.encode_mouse(
0, col as u16, row as u16, true, true,
modifiers,
);
if !seq.is_empty() {
pane.pty.write(&seq);
}
}
} else if is_selecting && !self.has_mouse_tracking() {
// Terminal-native selection
}
}
}
}
// Also update terminal-native selection for rendering
if let Some(renderer) = &self.renderer {
if let Some((col, screen_row)) =
renderer.pixel_to_cell(position.x, position.y)
{
let scroll_offset = self.get_scroll_offset();
if let Some(geom) = pane_geom {
if let Some((col, screen_row)) = renderer.pane_pixel_to_cell(
position.x, position.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
) {
let scroll_offset = if let Some(tab) = self.active_tab() {
tab.panes.get(&geom.pane_id)
.map(|p| p.terminal.scroll_offset)
.unwrap_or(0)
} else { 0 };
let content_row =
screen_row as isize - scroll_offset as isize;
if let Some(tab) = self.active_tab_mut() {
if let Some(pane) = tab.active_pane_mut() {
if let Some(active_tab) = self.active_tab_mut() {
if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) {
if let Some(ref mut selection) =
pane.selection
{
@@ -3148,14 +3226,59 @@ impl ApplicationHandler<UserEvent> for App {
col,
row: content_row,
};
// Force GPU buffer upload so selection renders correctly
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
}
}
}
}
}
}
} else if is_selecting && !mouse_tracking {
// Terminal-native selection
if let Some(renderer) = &self.renderer {
if let Some(geom) = pane_geom {
if let Some((col, screen_row)) = renderer.pane_pixel_to_cell(
position.x, position.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
) {
let scroll_offset = if let Some(tab) = self.active_tab() {
tab.panes.get(&geom.pane_id)
.map(|p| p.terminal.scroll_offset)
.unwrap_or(0)
} else { 0 };
let content_row =
screen_row as isize - scroll_offset as isize;
if let Some(active_tab) = self.active_tab_mut() {
if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) {
if let Some(ref mut selection) =
pane.selection
{
selection.end = CellPosition {
col,
row: content_row,
};
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
self.request_redraw();
}
}
}
}
}
}
} else if let Some(down_pos) = self.mouse_down_pos {
if !self.has_mouse_tracking() {
let mouse_pane_tracking = pane_geom
.as_ref()
.map(|g| self.has_mouse_tracking_for_pane(g.pane_id))
.unwrap_or(false);
if !mouse_pane_tracking {
let dx = position.x - down_pos.x;
let dy = position.y - down_pos.y;
let distance_sq = dx * dx + dy * dy;
@@ -3170,17 +3293,28 @@ impl ApplicationHandler<UserEvent> for App {
if distance_sq > threshold * threshold {
// Dragged far enough, start selection
if let Some(renderer) = &self.renderer {
if let Some(geom) = pane_geom {
if let Some((start_col, start_screen_row)) =
renderer
.pixel_to_cell(down_pos.x, down_pos.y)
{
if let Some((end_col, end_screen_row)) =
renderer.pixel_to_cell(
position.x, position.y,
renderer.pane_pixel_to_cell(
down_pos.x, down_pos.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
)
{
let scroll_offset =
self.get_scroll_offset() as isize;
if let Some((end_col, end_screen_row)) =
renderer.pane_pixel_to_cell(
position.x, position.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
)
{
let scroll_offset = if let Some(tab) = self.active_tab() {
tab.panes.get(&geom.pane_id)
.map(|p| p.terminal.scroll_offset)
.unwrap_or(0) as isize
} else { 0 };
let start_pos = CellPosition {
col: start_col,
row: start_screen_row as isize
@@ -3192,10 +3326,10 @@ impl ApplicationHandler<UserEvent> for App {
- scroll_offset,
};
if let Some(tab) = self.active_tab_mut()
if let Some(active_tab) = self.active_tab_mut()
{
if let Some(pane) =
tab.active_pane_mut()
active_tab.panes.get_mut(&geom.pane_id)
{
pane.selection =
Some(Selection {
@@ -3203,6 +3337,10 @@ impl ApplicationHandler<UserEvent> for App {
end: end_pos,
});
pane.is_selecting = true;
// Force GPU buffer upload so selection renders correctly
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
self.request_redraw();
}
}
@@ -3213,6 +3351,7 @@ impl ApplicationHandler<UserEvent> for App {
}
}
}
}
WindowEvent::MouseInput { state, button, .. } => {
let button_code = match button {
@@ -3222,34 +3361,74 @@ impl ApplicationHandler<UserEvent> for App {
_ => return,
};
if self.has_mouse_tracking() {
if let Some(renderer) = &self.renderer {
if let Some((col, row)) = renderer.pixel_to_cell(
// Find the pane under the mouse for pane-aware coordinate conversion
let pane_geom = if let Some(tab) = self.active_tab() {
tab.split_root.find_pane_at_pixel(
self.cursor_position.x,
self.cursor_position.y,
)
} else {
None
};
// Switch focus if clicking on a different pane
if let Some(geom) = &pane_geom {
if let Some(tab) = self.active_tab_mut() {
if tab.active_pane != geom.pane_id {
if tab.focus_pane_by_id(geom.pane_id) {
self.needs_redraw = true;
}
}
}
}
let mouse_tracking = pane_geom
.as_ref()
.map(|g| self.has_mouse_tracking_for_pane(g.pane_id))
.unwrap_or(false);
if mouse_tracking {
if let Some(renderer) = &self.renderer {
if let Some(geom) = pane_geom {
if let Some((col, row)) = renderer.pane_pixel_to_cell(
self.cursor_position.x,
self.cursor_position.y,
geom.x, geom.y,
geom.width, geom.height,
geom.cols, geom.rows,
) {
let pressed = state == ElementState::Pressed;
self.send_mouse_event(
let modifiers = self.get_mouse_modifiers();
if let Some(active_tab) = self.active_tab_mut() {
if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) {
let seq = pane.terminal.encode_mouse(
button_code,
col as u16,
row as u16,
pressed,
false,
modifiers,
);
if !seq.is_empty() {
pane.pty.write(&seq);
}
if button == MouseButton::Left {
if let Some(tab) = self.active_tab_mut() {
if let Some(pane) = tab.active_pane_mut() {
pane.is_selecting = pressed;
}
}
}
}
}
}
if mouse_tracking {
if let Some(tab) = self.active_tab_mut() {
if let Some(pane) = tab.active_pane_mut() {
pane.selection = None;
}
}
}
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
} else if button == MouseButton::Left {
match state {
ElementState::Pressed => {
@@ -3260,6 +3439,9 @@ impl ApplicationHandler<UserEvent> for App {
pane.is_selecting = false;
}
}
if let Some(renderer) = &mut self.renderer {
renderer.force_full_redraw();
}
}
ElementState::Released => {
self.mouse_down_pos = None;
@@ -3318,7 +3500,16 @@ impl ApplicationHandler<UserEvent> for App {
|| !self.edge_glows.is_empty()
|| self.tabs.iter().any(|tab| {
tab.panes.values().any(|pane| pane.terminal.image_storage.has_animations())
});
})
|| self.tabs.iter().any(|tab| {
tab.panes.values().any(|pane| {
pane.selection.is_some() || pane.is_selecting
})
})
|| self.renderer.as_ref().map(|r| r.has_pending_redraw()).unwrap_or(false);
if need_render {
}
if !need_render {
return;
@@ -3335,34 +3526,12 @@ impl ApplicationHandler<UserEvent> for App {
// Log cumulative stats every second (only with render_timing feature)
#[cfg(feature = "render_timing")]
if self.last_stats_log.elapsed() >= Duration::from_secs(1) {
let parse_ms = self.total_parse_ns as f64 / 1_000_000.0;
let render_ms = self.total_render_ns as f64 / 1_000_000.0;
log::info!(
"STATS: parse={:.1}ms/{} render={:.1}ms/{} ratio={:.2}",
parse_ms,
self.parse_count,
render_ms,
self.render_count,
if parse_ms > 0.0 {
render_ms / parse_ms
} else {
0.0
}
);
self.total_parse_ns = 0;
self.total_render_ns = 0;
self.parse_count = 0;
self.render_count = 0;
self.last_stats_log = std::time::Instant::now();
}
#[cfg(feature = "render_timing")]
{
let frame_time = frame_start.elapsed();
if frame_time.as_millis() > 16 {
log::info!("Slow frame: {:?}", frame_time);
}
}
}
_ => {}
@@ -3372,7 +3541,6 @@ impl ApplicationHandler<UserEvent> for App {
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// Check if all tabs have exited
if self.tabs.is_empty() {
log::info!("All tabs closed, exiting");
event_loop.exit();
return;
}
@@ -3382,7 +3550,6 @@ impl ApplicationHandler<UserEvent> for App {
let mut tabs_removed = false;
while i < self.tabs.len() {
if self.tabs[i].child_exited() {
log::info!("Tab {} shell exited", i);
self.tabs.remove(i);
tabs_removed = true;
if self.active_tab >= self.tabs.len() && !self.tabs.is_empty() {
@@ -3403,7 +3570,7 @@ impl ApplicationHandler<UserEvent> for App {
}
if self.tabs.is_empty() {
log::info!("All tabs closed, exiting");
event_loop.exit();
return;
}
@@ -3494,7 +3661,7 @@ fn setup_config_watcher(
return None;
}
log::info!("Config hot-reload enabled, watching {:?}", watch_path);
Some(watcher)
}
@@ -3504,11 +3671,11 @@ fn main() {
)
.init();
log::info!("Starting ZTerm");
// Check for existing instance
if signal_existing_instance() {
log::info!("Signaled existing instance, exiting");
return;
}
+70 -101
View File
@@ -1552,6 +1552,53 @@ impl Renderer {
Some((col, row))
}
/// Converts a pixel position to a terminal cell position relative to a specific pane.
/// `pane_x` and `pane_y` are the pane's pixel offset within the grid area.
/// `pane_width` and `pane_height` are the pane's dimensions in pixels.
/// `pane_cols` and `pane_rows` are the pane's dimensions in cells.
/// Returns None if the position is outside this pane's area.
pub fn pane_pixel_to_cell(
&self,
x: f64,
y: f64,
pane_x: f32,
pane_y: f32,
pane_width: f32,
pane_height: f32,
pane_cols: usize,
pane_rows: usize,
) -> Option<(usize, usize)> {
let terminal_y_offset = self.terminal_y_offset();
let grid_x_offset = self.grid_x_offset();
let grid_y_offset = self.grid_y_offset();
// Convert pane pixel offset to screen pixel offset
let pane_screen_x = grid_x_offset + pane_x;
let pane_screen_y = terminal_y_offset + grid_y_offset + pane_y;
// Check if position is within this pane's screen bounds
if (x as f32) < pane_screen_x
|| (x as f32) >= pane_screen_x + pane_width
|| (y as f32) < pane_screen_y
|| (y as f32) >= pane_screen_y + pane_height
{
return None;
}
// Calculate cell position relative to pane origin
let local_x = (x as f32) - pane_screen_x;
let local_y = (y as f32) - pane_screen_y;
let col = (local_x / self.cell_metrics.cell_width as f32).floor() as usize;
let row = (local_y / self.cell_metrics.cell_height as f32).floor() as usize;
if col >= pane_cols || row >= pane_rows {
return None;
}
Some((col, row))
}
/// Updates the scale factor and recalculates font/cell dimensions.
/// Returns true if the cell dimensions changed (terminal needs resize).
pub fn set_scale_factor(&mut self, new_scale: f64) -> bool {
@@ -1589,11 +1636,6 @@ impl Renderer {
// Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled();
log::info!(
"Scale factor changed to {}: font {}px -> {}px, cell: {}x{}, baseline: {}",
new_scale, self.base_font_size, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height, self.cell_metrics.baseline
);
// Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas();
@@ -1648,11 +1690,6 @@ impl Renderer {
// Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled();
log::info!(
"Font size changed to {}px -> {}px, cell: {}x{}, baseline: {}",
size, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height, self.cell_metrics.baseline
);
// Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas();
@@ -1666,7 +1703,7 @@ impl Renderer {
/// NOTE: This should ONLY be called for font/scale changes, NOT when atlas is full
/// (for that case, we add a new layer via add_atlas_layer()).
fn reset_atlas(&mut self) {
log::info!("Resetting glyph atlas (font/scale changed)");
// Clear all glyph caches - they need to be re-rasterized at new size
self.char_cache.clear();
@@ -2146,6 +2183,16 @@ impl Renderer {
self.cells_dirty = true;
}
/// Check if a full redraw is pending.
pub fn has_pending_redraw(&self) -> bool {
self.cells_dirty
}
/// Called after a render to clear the pending redraw flag.
pub fn clear_pending_redraw(&mut self) {
self.cells_dirty = false;
}
/// Update GPU cell buffer from terminal content.
/// Like Kitty, this only processes dirty lines to minimize work.
///
@@ -2163,14 +2210,17 @@ impl Renderer {
self.cells_dirty = true;
}
// Check if this terminal has any dirty lines
let has_dirty = terminal.has_any_dirty_line();
// First pass: ensure all characters have sprites
// This needs mutable access to self for sprite creation
// Like Kitty's render_line(), detect PUA+space patterns for multi-cell rendering
// OPTIMIZATION: Only process dirty lines or when full rebuild is needed
// OPTIMIZATION: Use get_visible_row() to avoid Vec allocation
for row_idx in 0..rows {
// Skip clean lines (unless size changed, which sets cells_dirty)
if !self.cells_dirty && !terminal.is_line_dirty(row_idx) {
// Skip clean lines (unless size changed or terminal has dirty lines)
if !self.cells_dirty && !has_dirty && !terminal.is_line_dirty(row_idx) {
continue;
}
@@ -2344,61 +2394,21 @@ impl Renderer {
}
// Second pass: convert cells to GPU format
// OPTIMIZATION: Use get_visible_row() to avoid Vec allocation
// Always update self.gpu_cells from the current terminal to avoid
// stale data from a previous pane being written to the wrong GPU buffer.
let mut any_updated = false;
// DEBUG: Log grid dimensions and buffer state
static DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let frame_num = DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if frame_num % 60 == 0 { // Log every 60 frames (~1 second at 60fps)
log::info!("DEBUG update_gpu_cells: cols={} rows={} total={} gpu_cells.len={} cells_dirty={}",
cols, rows, total_cells, self.gpu_cells.len(), self.cells_dirty);
}
// If we did a full reset or size changed, update all lines
if self.cells_dirty {
static ROW_DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let row_frame = ROW_DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if row_frame % 60 == 0 {
let first_col: String = (0..rows).filter_map(|r| {
terminal.get_visible_row(r).and_then(|row| {
row.first().map(|cell| {
let c = cell.character;
if c == '\0' { ' ' } else { c }
})
})
}).collect();
log::info!("DEBUG col0: \"{}\"", first_col);
}
for row_idx in 0..rows {
if let Some(row) = terminal.get_visible_row(row_idx) {
let start = row_idx * cols;
let end = start + cols;
if end > self.gpu_cells.len() {
log::error!("DEBUG BUG: row_idx={} start={} end={} but gpu_cells.len={}",
row_idx, start, end, self.gpu_cells.len());
continue;
}
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
}
}
self.cells_dirty = false;
any_updated = true;
} else {
// Only update dirty lines - use is_line_dirty() which handles all 256 lines
for row_idx in 0..rows {
if terminal.is_line_dirty(row_idx) {
if let Some(row) = terminal.get_visible_row(row_idx) {
let start = row_idx * cols;
let end = start + cols;
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
any_updated = true;
}
}
}
}
@@ -3681,7 +3691,7 @@ impl Renderer {
return;
}
log::info!("Adding atlas layer {} (was on layer {})", new_layer, self.atlas_current_layer);
// Create real texture for the new layer (replacing the dummy)
self.ensure_atlas_layer_capacity(new_layer);
@@ -3708,7 +3718,7 @@ impl Renderer {
return;
}
log::info!("Adding atlas layer {} (replacing dummy texture)", target_layer);
// Create new real texture (8192x8192)
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
@@ -4636,7 +4646,7 @@ impl Renderer {
{
let update_time = t0.elapsed();
if update_time.as_micros() > 500 {
log::info!("update_gpu_cells took {:?}", update_time);
}
}
@@ -4680,35 +4690,6 @@ impl Renderer {
selection_end_row: sel_end_row,
};
// DEBUG: Log grid params every 60 frames
static PANE_DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let pane_frame = PANE_DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if pane_frame % 60 == 0 {
log::info!("DEBUG pane {}: grid_params cols={} rows={} gpu_cells.len={} expected={}",
info.pane_id, grid_params.cols, grid_params.rows,
self.gpu_cells.len(), (grid_params.cols * grid_params.rows) as usize);
// Sample a few cells to see if sprite indices look reasonable
if !self.gpu_cells.is_empty() {
let sample_indices = [0, 1, 2, cols as usize, cols as usize + 1];
for &idx in &sample_indices {
if idx < self.gpu_cells.len() {
let cell = &self.gpu_cells[idx];
let sprite_idx = cell.sprite_idx & !0x80000000;
log::info!("DEBUG cell[{}]: sprite_idx={} fg={:#x} bg={:#x}",
idx, sprite_idx, cell.fg, cell.bg);
if sprite_idx > 0 && (sprite_idx as usize) < self.sprite_info.len() {
let sprite = &self.sprite_info[sprite_idx as usize];
log::info!("DEBUG sprite[{}]: uv=({:.3},{:.3},{:.3},{:.3}) layer={} size=({:.1},{:.1})",
sprite_idx, sprite.uv[0], sprite.uv[1], sprite.uv[2], sprite.uv[3],
sprite.layer, sprite.size[0], sprite.size[1]);
}
}
}
}
}
// Upload this pane's cell data to its own buffer (like Kitty's send_cell_data_to_gpu)
// This happens BEFORE the render pass, so each pane has its own data
if let Some(pane_res) = self.pane_resources.get(&info.pane_id) {
@@ -4770,7 +4751,7 @@ impl Renderer {
{
let pane_loop_time = pane_loop_start.elapsed();
if pane_loop_time.as_micros() > 500 {
log::info!("pane_loop took {:?}", pane_loop_time);
}
}
@@ -5259,19 +5240,7 @@ impl Renderer {
let after_submit = frame_start.elapsed();
output.present();
// Log timing if frame took more than 1ms (only with render_timing feature)
#[cfg(feature = "render_timing")]
{
let after_present = frame_start.elapsed();
if after_present.as_micros() > 1000 {
log::info!("render_panes: before_submit={:?} submit={:?} present={:?} total={:?}",
before_submit,
after_submit - before_submit,
after_present - after_submit,
after_present);
}
}
self.clear_pending_redraw();
Ok(())
}
+8 -103
View File
@@ -298,87 +298,6 @@ struct AlternateScreen {
scroll_bottom: usize,
}
/// Timing stats for performance debugging.
/// Only populated when the `render_timing` feature is enabled.
#[derive(Debug, Default)]
pub struct ProcessingStats {
#[cfg(feature = "render_timing")]
/// Total time spent in scroll_up operations (nanoseconds).
pub scroll_up_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of scroll_up calls.
pub scroll_up_count: u32,
#[cfg(feature = "render_timing")]
/// Total time spent in scrollback operations (nanoseconds).
pub scrollback_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in VecDeque pop_front.
pub pop_front_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in VecDeque push_back.
pub push_back_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in mem::swap.
pub swap_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in line clearing (nanoseconds).
pub clear_line_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in text handler (nanoseconds).
pub text_handler_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in CSI handler (nanoseconds).
pub csi_handler_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of CSI sequences processed.
pub csi_count: u32,
#[cfg(feature = "render_timing")]
/// Number of characters processed.
pub chars_processed: u32,
#[cfg(feature = "render_timing")]
/// Total time spent in VT parser (consume_input) - nanoseconds.
pub vt_parser_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of consume_input calls.
pub consume_input_count: u32,
}
impl ProcessingStats {
#[cfg(feature = "render_timing")]
pub fn reset(&mut self) {
*self = Self::default();
}
#[cfg(not(feature = "render_timing"))]
pub fn reset(&mut self) {}
#[cfg(feature = "render_timing")]
pub fn log_if_slow(&self, threshold_ms: u64) {
let total_ms =
(self.scroll_up_ns + self.text_handler_ns + self.csi_handler_ns)
/ 1_000_000;
if total_ms >= threshold_ms {
let vt_only_ns = self
.vt_parser_ns
.saturating_sub(self.text_handler_ns + self.csi_handler_ns);
log::info!(
"[PARSE_DETAIL] text={:.2}ms ({}chars) csi={:.2}ms ({}x) vt_only={:.2}ms ({}calls) scroll={:.2}ms ({}x)",
self.text_handler_ns as f64 / 1_000_000.0,
self.chars_processed,
self.csi_handler_ns as f64 / 1_000_000.0,
self.csi_count,
vt_only_ns as f64 / 1_000_000.0,
self.consume_input_count,
self.scroll_up_ns as f64 / 1_000_000.0,
self.scroll_up_count,
);
}
}
#[cfg(not(feature = "render_timing"))]
pub fn log_if_slow(&self, _threshold_ms: u64) {}
}
/// Kitty-style ring buffer for scrollback history.
///
/// Pre-allocates all lines upfront to avoid allocation during scrolling.
@@ -569,7 +488,7 @@ pub struct Terminal {
/// Synchronized output mode (for reducing flicker).
synchronized_output: bool,
/// Performance timing stats (for debugging).
pub stats: ProcessingStats,
/// Command queue for terminal-to-application communication.
/// Commands are added by OSC handlers and consumed by the application.
command_queue: Vec<TerminalCommand>,
@@ -587,12 +506,6 @@ impl Terminal {
/// Creates a new terminal with the given dimensions and scrollback limit.
pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
log::info!(
"Terminal::new: cols={}, rows={}, scroll_bottom={}",
cols,
rows,
rows.saturating_sub(1)
);
let grid = vec![vec![Cell::default(); cols]; rows];
let line_map: Vec<usize> = (0..rows).collect();
@@ -632,7 +545,7 @@ impl Terminal {
bracketed_paste: false,
focus_reporting: false,
synchronized_output: false,
stats: ProcessingStats::default(),
command_queue: Vec::new(),
image_storage: ImageStorage::new(),
cell_width: 10.0, // Default, will be set by renderer
@@ -668,6 +581,12 @@ impl Terminal {
}
}
/// Check if any line is dirty.
#[inline]
pub fn has_any_dirty_line(&self) -> bool {
self.dirty_lines[0] != 0 || self.dirty_lines[1] != 0 || self.dirty_lines[2] != 0 || self.dirty_lines[3] != 0
}
/// Clear all dirty line flags.
#[inline]
pub fn clear_dirty_lines(&mut self) {
@@ -789,14 +708,6 @@ impl Terminal {
return;
}
log::info!(
"Terminal::resize: {}x{} -> {}x{}",
self.cols,
self.rows,
cols,
rows
);
let old_cols = self.cols;
let old_rows = self.rows;
@@ -1692,12 +1603,6 @@ impl Handler for Terminal {
let statusline =
content.filter(|s| !s.is_empty());
log::info!(
"OSC 51: Set statusline: {:?}",
statusline
.as_ref()
.map(|s| format!("{} bytes", s.len()))
);
self.command_queue.push(
TerminalCommand::SetStatusline(statusline),
);