use std::{ borrow::Cow, collections::{BTreeMap, BTreeSet}, fmt::Write as _, hash::{Hash, Hasher}, io::{self, Stdout}, panic::{self, AssertUnwindSafe, PanicHookInfo}, sync::{Arc, LazyLock, Mutex}, time::{Duration, Instant}, }; #[cfg(unix)] use crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::{ cursor::Show, event::{ self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }, execute, terminal::{ BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }, }; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::{Constraint, Layout, Position, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use tree_sitter::Point; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ chrome::{ChromeLine, PopupAnchor, SegmentConfig}, diff::{ ChangedFile, ColumnEmpty, ColumnRow, ColumnView, DiffState, DisplayRow, FileStatus, Focus, HistoryComparison, PaneLayout, Phase, ViewMode, }, editor::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, ResolverLayout, ResolverStatus, ResolverView, ResolverWorkspace, Selection, StartupDiagnostic, WhitespaceScope, }, files::{DockSide, FilePicker, FilePresentation, FileView}, panes::{PaneRect, SplitAxis, split_rect}, plugin::{ Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, }, session::EditorSession, syntax::{SyntaxCache, SyntaxKind, SyntaxSpan}, terminal_colors::{TerminalColors, ThemeColors}, wrap::{Segment, WrapCache, caret_segment}, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"]; pub(crate) const TAB_WIDTH: usize = 4; type PanicHook = Box) + Send + Sync + 'static>; struct HookState { previous: Option, active: bool, } /// Owns terminal modes and panic cleanup through application startup and shutdown. pub struct TerminalSession { hook: Arc>, } struct ColorSchemeNotifications(bool); impl crossterm::Command for ColorSchemeNotifications { fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { f.write_str(if self.0 { "\x1b[?2031h" } else { "\x1b[?2031l" }) } #[cfg(windows)] fn execute_winapi(&self) -> io::Result<()> { Ok(()) } } fn restore_terminal() { let _ = disable_raw_mode(); #[cfg(unix)] let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); let _ = execute!( io::stdout(), EndSynchronizedUpdate, DisableBracketedPaste, DisableMouseCapture, DisableFocusChange, ColorSchemeNotifications(false), LeaveAlternateScreen, Show ); } impl TerminalSession { pub fn run(inherited_screen: bool, body: impl FnOnce() -> io::Result<()>) -> io::Result<()> { let hook = Arc::new(Mutex::new(HookState { previous: Some(panic::take_hook()), active: inherited_screen, })); let during_session = Arc::clone(&hook); panic::set_hook(Box::new(move |info| { let mut state = during_session .lock() .unwrap_or_else(|error| error.into_inner()); if state.active { restore_terminal(); state.active = false; } if let Some(previous) = &state.previous { previous(info); } })); let session = Self { hook }; // set_hook is forbidden on a panicking thread. Catch first, drop the // owner while not panicking, then resume the original panic unchanged. let result = panic::catch_unwind(AssertUnwindSafe(|| { enable_raw_mode()?; session .hook .lock() .unwrap_or_else(|error| error.into_inner()) .active = true; if !inherited_screen { execute!(io::stdout(), EnterAlternateScreen)?; // Restart inherits this screen and its keyboard stack entry. #[cfg(unix)] execute!( io::stdout(), PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES ) )?; } execute!( io::stdout(), EnableBracketedPaste, EnableMouseCapture, EnableFocusChange, ColorSchemeNotifications(true) )?; body() })); drop(session); match result { Ok(result) => result, Err(payload) => panic::resume_unwind(payload), } } /// Restores process-local raw/input state without exposing the normal screen. /// The guard remains armed if launching the replacement process fails. pub fn prepare_restart() -> io::Result<()> { disable_raw_mode()?; execute!( io::stdout(), EndSynchronizedUpdate, DisableBracketedPaste, DisableMouseCapture, DisableFocusChange, ColorSchemeNotifications(false) ) } } impl Drop for TerminalSession { fn drop(&mut self) { let mut state = self.hook.lock().unwrap_or_else(|error| error.into_inner()); if state.active { restore_terminal(); state.active = false; } let previous = state .previous .take() .expect("terminal session owns its prior hook"); drop(state); panic::set_hook(previous); } } #[derive(Default)] struct DocumentViewport { geometry: Option<(usize, Rect)>, manual: Option, text_width: usize, horizontal_extent: Option<(usize, u64, usize)>, wrap: WrapCache, continuations: BTreeMap, presentation: Option<(usize, bool, usize, usize)>, visible: Vec<(usize, usize, Segment)>, anchor: Option<(usize, u64, Cursor)>, } #[derive(Clone)] struct ManualViewport { buffer_id: usize, revision: u64, caret_reveal_revision: u64, mode: Mode, cursor: Cursor, carets: Vec, selections: Vec, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] struct VisualRow { source: usize, continuation: usize, } fn next_visual(cache: &mut WrapCache, lines: &[String], position: VisualRow) -> Option { if position.continuation + 1 < cache .segments(position.source, &lines[position.source]) .len() { Some(VisualRow { continuation: position.continuation + 1, ..position }) } else if position.source + 1 < lines.len() { Some(VisualRow { source: position.source + 1, continuation: 0, }) } else { None } } fn previous_visual( cache: &mut WrapCache, lines: &[String], position: VisualRow, ) -> Option { if position.continuation > 0 { Some(VisualRow { continuation: position.continuation - 1, ..position }) } else if position.source > 0 { let source = position.source - 1; Some(VisualRow { source, continuation: cache.segments(source, &lines[source]).len() - 1, }) } else { None } } fn visual_page( cache: &mut WrapCache, lines: &[String], start: VisualRow, height: usize, ) -> (VisualRow, usize) { let mut end = start; let mut count = usize::from(height > 0); while count < height { let Some(next) = next_visual(cache, lines, end) else { break; }; end = next; count += 1; } (end, count) } fn clamp_visual_bottom( cache: &mut WrapCache, lines: &[String], mut start: VisualRow, height: usize, ) -> VisualRow { let (_, count) = visual_page(cache, lines, start, height); for _ in count..height { let Some(previous) = previous_visual(cache, lines, start) else { break; }; start = previous; } start } fn scroll_margin(size: usize, distance: Option) -> usize { distance.unwrap_or(size / 2).min(size / 2) } fn scroll_offset(top: usize, cursor: usize, size: usize, distance: Option) -> usize { if size == 0 { return top; } let margin = scroll_margin(size, distance); if margin == size / 2 { return cursor.saturating_sub(margin); } if cursor < top.saturating_add(margin) { cursor.saturating_sub(margin) } else if cursor >= top.saturating_add(size - margin) { cursor.saturating_sub(size - margin - 1) } else { top } } #[derive(Default)] struct BufferViewports { offsets: BTreeMap, syntax: BTreeMap, document: DocumentViewport, document_buffer: Option, documents: BTreeMap, panels: WheelPanels, pane: Option, parked: BTreeMap, rects: Vec<(usize, Rect)>, theme: Option, diff_visible: bool, history: BTreeMap, history_key: Option, } #[derive(Default)] struct PaneViewport { offsets: BTreeMap, document: DocumentViewport, document_buffer: Option, documents: BTreeMap, panels: WheelPanels, } type HistoryKey = (usize, [usize; 2], Option); fn history_key(resolver: &ResolverWorkspace) -> Option { Some(( resolver.files.get(resolver.current_file)?.draft_buffer, resolver.history_sources, resolver.selected_base, )) } struct HistoryDiff { comparison: HistoryComparison, top: DiffWrapPosition, left: [usize; 3], widths: [usize; 3], wrapping: [bool; 3], wrap: [WrapCache; 3], visible: [Vec; 3], caret: Option<(usize, Cursor, u64, bool)>, } impl HistoryDiff { fn new(resolver: &ResolverWorkspace) -> Self { let file = &resolver.files[resolver.current_file]; let text = |index: Option| { index .and_then(|index| file.sources.get(index)) .map_or("", |source| source.text.as_str()) }; let comparison = HistoryComparison::new([ text(Some(resolver.history_sources[0])), text(resolver.selected_base), text(Some(resolver.history_sources[1])), ]); Self { comparison, top: DiffWrapPosition::default(), left: [0; 3], widths: [0; 3], wrapping: [false; 3], wrap: std::array::from_fn(|_| WrapCache::default()), visible: std::array::from_fn(|_| Vec::new()), caret: None, } } fn row_height(&mut self, row: usize) -> usize { let mut height = 1; for column in 0..3 { if self.wrapping[column] && self.widths[column] > 0 && let Some(source) = self.comparison.column(column).source_row(row) { let lines = self.comparison.column(column).lines; height = height.max( self.wrap[column] .segments(source, lines.get(source).map_or("", String::as_str)) .len(), ); } } height } fn step(&mut self, down: bool) { if down { if self.top.continuation + 1 < self.row_height(self.top.display) { self.top.continuation += 1; } else if self.top.display + 1 < self.comparison.row_count() { self.top.display += 1; self.top.continuation = 0; } } else if self.top.continuation > 0 { self.top.continuation -= 1; } else if self.top.display > 0 { self.top.display -= 1; self.top.continuation = self.row_height(self.top.display) - 1; } } fn fill(&mut self, height: usize) { for rows in &mut self.visible { rows.clear(); } let top = self.top; for _ in 0..height { for column in 0..3 { let mut segments = [None; 2]; let mut final_segments = [false; 2]; let projection = self.comparison.column(column); let side = usize::from(projection.focus == Focus::After); if let Some(source) = projection.source_row(self.top.display) { let text = projection.lines.get(source).map_or("", String::as_str); if self.wrapping[column] { let parts = self.wrap[column].segments(source, text); segments[side] = parts.get(self.top.continuation).copied(); final_segments[side] = self.top.continuation + 1 == parts.len(); } else if self.top.continuation == 0 { segments[side] = Some(Segment { start: self.left[column], end: self.left[column].saturating_add(self.widths[column]), }); final_segments[side] = true; } } self.visible[column].push(WrappedDiffRow { position: self.top, segments, final_segments, scope: [false; 2], unwrapped: false, }); } let previous = self.top; self.step(true); if self.top == previous { break; } } self.top = top; } } fn pane_rect(area: Rect) -> PaneRect { PaneRect { x: area.x, y: area.y, width: area.width, height: area.height, } } fn terminal_rect(area: PaneRect) -> Rect { Rect::new(area.x, area.y, area.width, area.height) } impl BufferViewports { fn set_theme(&mut self, theme: Option) { if self.theme == theme { return; } self.theme = theme; let colors = DiffColors::new(theme); self.panels.diff_colors = colors; for pane in self.parked.values_mut() { pane.panels.diff_colors = colors; } } fn activate(&mut self, id: usize, editor: &Editor) { if self.pane == Some(id) { return; } if self.pane.is_none() { self.pane = Some(id); return; } if !self.parked.contains_key(&id) { let mut inherited = (|| { let buffer = editor.pane_buffer_id(id)?; let source = editor.pane_origin(id); let (offsets, document, document_buffer, documents) = if source == self.pane { ( &self.offsets, &self.document, self.document_buffer, &self.documents, ) } else { let source = self.parked.get(&source?)?; ( &source.offsets, &source.document, source.document_buffer, &source.documents, ) }; let document = if document_buffer == Some(buffer) { Some(document) } else { documents.get(&buffer) }; Some(PaneViewport { offsets: offsets .get(&buffer) .map_or_else(BTreeMap::new, |offset| BTreeMap::from([(buffer, *offset)])), document: DocumentViewport { continuations: document .and_then(|document| document.continuations.get(&buffer)) .map_or_else(BTreeMap::new, |continuation| { BTreeMap::from([(buffer, *continuation)]) }), manual: document.and_then(|document| document.manual.clone()), presentation: document.and_then(|document| document.presentation), ..DocumentViewport::default() }, ..PaneViewport::default() }) })() .unwrap_or_default(); inherited.panels.diff_colors = self.panels.diff_colors; self.parked.insert(id, inherited); } let outgoing = self .parked .entry(self.pane.expect("current pane exists")) .or_default(); std::mem::swap(&mut self.offsets, &mut outgoing.offsets); std::mem::swap(&mut self.document, &mut outgoing.document); std::mem::swap(&mut self.document_buffer, &mut outgoing.document_buffer); std::mem::swap(&mut self.documents, &mut outgoing.documents); std::mem::swap(&mut self.panels, &mut outgoing.panels); let incoming = self.parked.get_mut(&id).expect("incoming pane initialized"); std::mem::swap(&mut self.offsets, &mut incoming.offsets); std::mem::swap(&mut self.document, &mut incoming.document); std::mem::swap(&mut self.document_buffer, &mut incoming.document_buffer); std::mem::swap(&mut self.documents, &mut incoming.documents); std::mem::swap(&mut self.panels, &mut incoming.panels); self.pane = Some(id); } fn activate_document(&mut self, buffer: usize) { if self.document_buffer == Some(buffer) { return; } if self.document_buffer.is_none() { self.document_buffer = Some(buffer); return; } let previous = self.document_buffer.expect("current document exists"); std::mem::swap( &mut self.document, self.documents.entry(previous).or_default(), ); std::mem::swap( &mut self.document, self.documents.entry(buffer).or_default(), ); self.document_buffer = Some(buffer); } fn draw(&mut self, frame: &mut Frame, editor: &mut Editor) { let focused = editor.pane_id(); let viewport_pane = editor .resolver() .filter(|resolver| resolver.view == ResolverView::Resolution) .map_or(focused, |resolver| resolver.draft_pane); self.activate(viewport_pane, editor); self.parked .retain(|id, _| editor.pane_buffer_id(*id).is_some()); self.syntax.retain(|id, _| editor.has_buffer(*id)); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); let full_workspace = draw_bars(frame, editor, frame.area(), syntax); let resolver_resolution = editor .resolver() .is_some_and(|resolver| resolver.view == ResolverView::Resolution); let resolver_history = editor .resolver() .is_some_and(|resolver| resolver.view == ResolverView::History); let multiple = !resolver_resolution && editor.pane_layout().len() > 1; let layout_pane = editor .review_panel() .and_then(|pane| editor.pane_origin(pane)) .unwrap_or(focused); let (workspace, dock) = editor.with_render_pane(layout_pane, |view| match view.file_picker() { Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout( full_workspace, if view.picker_diff().is_some() { DockSide::Right } else { view.file_explorer_side() }, picker.focused, ), _ => (full_workspace, None), }); editor.set_pane_bounds(pane_rect(workspace)); self.rects.clear(); if resolver_history { let layout = resolver_history_layout( workspace, editor.resolver().unwrap(), focused, editor.resolve_history_layout, ); draw_resolver_heading(frame, editor.resolver().unwrap(), layout.heading); self.draw_resolver_history(frame, editor, &layout); } else if resolver_resolution { self.rects.push((viewport_pane, workspace)); } else { editor .pane_layout() .visit_rects(pane_rect(workspace), |id, area| { self.rects.push((id, terminal_rect(area))); }); } // Paint focused overlays last, so an adjacent leaf cannot erase them. for active_pass in [false, true].into_iter().filter(|_| !resolver_history) { for index in 0..self.rects.len() { let (id, mut leaf) = self.rects[index]; if ((id == focused) || resolver_resolution) != active_pass { continue; } self.activate(id, editor); self.panels.workspace_managed = true; if multiple { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if id == focused { Color::Cyan } else { Color::DarkGray })); let inner = block.inner(leaf); frame.render_widget(block, leaf); leaf = inner; } let render_pane = if resolver_resolution { focused } else { id }; editor.with_render_pane(render_pane, |view| { self.activate_document(view.buffer_id()); self.documents.retain(|id, _| view.has_buffer(*id)); self.offsets.retain(|id, _| view.has_buffer(*id)); self.document .continuations .retain(|id, _| view.has_buffer(*id)); let (top, left) = self.offsets.entry(view.buffer_id()).or_default(); let syntax = self.syntax.entry(view.buffer_id()).or_default(); draw_document( frame, view, leaf, full_workspace, top, left, syntax, &mut self.document, &mut self.panels, ); }); if let Some(geometry) = &self.panels.diff { editor.set_diff_pane_bounds( geometry .regions .map(|(area, focus)| (focus, pane_rect(area))), ); } } } self.activate(viewport_pane, editor); self.panels.workspace_managed = false; if self.rects.is_empty() { self.panels.begin_frame(); self.document.geometry = None; } if let Some(panel) = dock { self.activate(layout_pane, editor); editor.with_render_pane(layout_pane, |view| { if let Some(picker) = view.file_picker() { self.panels.picker_scroll.distance = view.scroll_distance(); draw_file_list( frame, picker, panel, view.file_navigation_hint(), &mut self.panels, view.picker_diff().and_then(|diff| { diff.comparison.as_ref().map(|comparison| { (comparison.files.as_slice(), &diff.reviewed_files) }) }), ); } }); if let Some(geometry) = &mut self.panels.diff { geometry.regions[0] = (panel, Focus::Files); editor.set_diff_pane_bounds( geometry .regions .map(|(area, focus)| (focus, pane_rect(area))), ); } } if resolver_history { editor.set_resolver_history_bounds( self.rects.iter().map(|(id, area)| (*id, pane_rect(*area))), dock.map(pane_rect), ); if let Some(diff) = editor.picker_diff() { self.panels.diff = Some(DiffGeometry { regions: [ (dock.unwrap_or_default(), Focus::Files), (Rect::default(), Focus::Before), (Rect::default(), Focus::After), ], viewport: None, text_widths: [0; 2], wrapping: diff.word_wrap, view_mode: diff.view_mode, split_axis: diff.split_axis, caret_row: None, }); } } if resolver_resolution { self.rects.clear(); if let Some(geometry) = &self.panels.diff { let resolver = editor.resolver().unwrap(); for (pane, region) in [ (Some(resolver.draft_pane), geometry.regions[1].0), (resolver.alternative_pane, geometry.regions[2].0), ] { if let Some(pane) = pane.filter(|_| !region.is_empty()) { self.rects.push((pane, region)); } } } } if let Some(panel) = editor.review_panel() { let source = editor.pane_origin(panel); let area = self .rects .iter() .find(|(id, _)| Some(*id) == source) .map_or(full_workspace, |(_, area)| *area); let area = floating_panel(area); self.rects.insert(0, (panel, area)); self.activate(panel, editor); self.panels.workspace_managed = true; editor.with_render_pane(panel, |view| { frame.render_widget(Clear, area); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) .title(display_text( view.review_panel_title().unwrap_or("Local review"), )); let content = block.inner(area); frame.render_widget(block, area); self.activate_document(view.buffer_id()); let (top, left) = self.offsets.entry(view.buffer_id()).or_default(); let syntax = self.syntax.entry(view.buffer_id()).or_default(); draw_document( frame, view, content, full_workspace, top, left, syntax, &mut self.document, &mut self.panels, ); }); self.panels.workspace_managed = false; } if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) { draw_diff( frame, diff, full_workspace, editor.selection_navigation_hint(), editor.file_navigation_hint(), &mut self.panels, Some(editor), ); } else { draw_overlays( frame, editor, full_workspace, self.panels.caret, &mut self.panels, ); } self.diff_visible = self .panels .diff .iter() .chain( self.parked .iter() .filter(|(id, _)| editor.pane_layout().contains(**id)) .filter_map(|(_, pane)| pane.panels.diff.as_ref()), ) .any(|geometry| geometry.viewport.is_some()); } fn draw_resolver_history( &mut self, frame: &mut Frame, editor: &mut Editor, layout: &ResolverHistoryLayout, ) { self.panels.begin_frame(); self.document.geometry = None; let resolver = editor.resolver().unwrap(); let Some(key) = history_key(resolver) else { return; }; self.history_key = Some(key); self.history .retain(|(buffer, _, _), _| editor.has_buffer(*buffer)); let history = self .history .entry(key) .or_insert_with(|| HistoryDiff::new(resolver)); let mut panes = [None; 3]; for (slot, &(index, panel)) in layout.history.iter().enumerate() { let column = if resolver.selected_base == Some(index) { 1 } else if resolver.history_sources[0] == index { 0 } else { 2 }; let pane = resolver.history_panes[index]; let source = &resolver.files[resolver.current_file].sources[index]; let content = resolver_document_panel( frame, panel, &format!("{} · readonly", source.label), !editor.file_picker().is_some_and(|picker| picker.focused) && pane == editor.pane_id(), ); panes[slot] = Some((column, pane, content)); self.rects.push((pane, content)); } let previous_widths = history.widths; history.widths = [0; 3]; for &(column, pane, content) in panes.iter().flatten() { editor.with_render_pane(pane, |view| { let projection = history.comparison.column(column); let width = usize::from(content.width); history.widths[column] = width.saturating_sub(diff_gutter( projection.diff, projection.file, width, projection.focus, )); history.wrapping[column] = view.word_wrap(); history.wrap[column].prepare((view.buffer_id(), 0), history.widths[column]); }); } let height = panes .iter() .flatten() .map(|(_, _, area)| usize::from(area.height)) .min() .unwrap_or(0); history.top.continuation = history .top .continuation .min(history.row_height(history.top.display) - 1); history.fill(height); if let Some(&(column, pane, content)) = panes .iter() .flatten() .find(|(_, pane, _)| *pane == editor.pane_id()) { let cursor = editor.cursor(); let identity = ( pane, cursor, editor.caret_reveal_revision(), history.wrapping[column], ); if !editor.file_picker().is_some_and(|picker| picker.focused) && (history.caret != Some(identity) || previous_widths != history.widths) && let Some(display) = history .comparison .column(column) .view() .display_row(cursor.row) { let column_offset = display_column(&editor.lines()[cursor.row], cursor.column); let continuation = if history.wrapping[column] { caret_segment( history.wrap[column].segments(cursor.row, &editor.lines()[cursor.row]), column_offset, ) } else { history.left[column] = scroll_offset( history.left[column], column_offset, history.widths[column], editor.scroll_distance(), ); 0 }; let caret = DiffWrapPosition { display, continuation, }; let screen = history.visible[column] .iter() .position(|row| row.position == caret); let height = usize::from(content.height); let margin = scroll_margin(height, editor.scroll_distance()); let anchor = if height == 0 { None } else if caret < history.top || screen.is_some_and(|row| row < margin) { Some(margin) } else if screen.is_none_or(|row| row >= height.saturating_sub(margin)) { Some(height.saturating_sub(margin + 1)) } else { None }; if let Some(anchor) = anchor { history.top = caret; for _ in 0..anchor { history.step(false); } } history.fill(height); } history.caret = Some(identity); } let scroll = editor.take_viewport_scroll(height); for _ in 0..scroll.unsigned_abs() { let previous = history.top; history.step(scroll > 0); if history.top == previous { break; } } if scroll != 0 { history.fill(height); } for (column, pane, content) in panes.into_iter().flatten() { editor.with_render_pane(pane, |view| { let projection = history.comparison.column(column); let syntax = self.syntax.entry(view.buffer_id()).or_default(); let source_rows = history.visible[column] .iter() .filter_map(|row| projection.source_row(row.position.display)); let first = source_rows .clone() .min() .unwrap_or(0) .min(view.lines().len() - 1); let last = source_rows .max() .unwrap_or(first) .min(view.lines().len() - 1); syntax.update_visible( view.lines(), view.path(), view.text_revision(), std::slice::from_ref(&(first..last + 1)), ); let text_area = draw_diff_pane( frame, projection.diff, projection.file, content, projection.focus == Focus::Before, [&*syntax, &*syntax], [view.semantic_tokens(), view.semantic_tokens()], (history.top.display, &mut history.left[column], None), Some(&history.visible[column]), self.panels.diff_colors, view.highlight_trailing_whitespace(), Some(view.whitespace_characters()), view.whitespace_scope(), view.resolver(), true, ); if view.render_pane_focused() && !view.file_picker().is_some_and(|picker| picker.focused) { self.panels.caret = paint_diff_editor( frame, view, projection.view(), text_area, (history.top.display, history.left[column]), Some(&history.visible[column]), ) .map(|caret| (text_area, caret)); } }); } } fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) { self.draw(frame, session.editor_mut()); if let Some((comparison_id, file, top, left_before, left_after)) = self .panels .diff .as_ref() .and_then(|geometry| geometry.viewport) { session.set_diff_viewport(comparison_id, file, top, left_before, left_after); } if session.editor().resolver().is_some() { return; } for (pane_id, _) in &self.rects { let Some(pane) = self.parked.get(pane_id) else { continue; }; if let Some((id, file, top, before, after)) = pane .panels .diff .as_ref() .and_then(|geometry| geometry.viewport) { session.set_diff_viewport(id, file, top, before, after); } } } // Shift-wheel is the fallback for terminals without native horizontal reports. // Keep classification shared with popup routing. fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { if wheel_kind(&mouse).is_none() { return false; } if self.panels.scroll(session, mouse) { return true; } let focused = session.editor().pane_id(); let target = self.rects.iter().find_map(|(id, area)| { area.contains(Position::new(mouse.column, mouse.row)) .then_some(*id) }); let target = if let Some(panel) = session.editor().review_panel() { if target != Some(panel) { return true; } panel } else if let Some(target) = target { target } else { return false; }; if let Some(resolver) = session .editor() .resolver() .filter(|resolver| resolver.view == ResolverView::History) && let Some(key) = self.history_key && let Some(history) = self.history.get_mut(&key) { let source = resolver .history_panes .iter() .position(|pane| *pane == target) .unwrap(); let column = if resolver.selected_base == Some(source) { 1 } else if resolver.history_sources[0] == source { 0 } else { 2 }; match wheel_kind(&mouse).unwrap() { MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => { let down = wheel_kind(&mouse) == Some(MouseEventKind::ScrollDown); for _ in 0..3 { history.step(down); } } MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight if !history.wrapping[column] => { let right = wheel_kind(&mouse) == Some(MouseEventKind::ScrollRight); let left = if right { history.left[column].saturating_add(3) } else { history.left[column].saturating_sub(3) }; let projection = history.comparison.column(column); history.left[column] = projection.diff.bound_horizontal( projection.focus, left, history.widths[column], ); } _ => {} } return true; } self.activate(target, session.editor()); let handled = if self.panels.diff.is_some() { self.panels.scroll_comparison(session, mouse, target); true } else { session .editor_mut() .with_render_pane(target, |editor| self.scroll_document(editor, mouse)) }; self.activate(focused, session.editor()); handled } fn scroll_document(&mut self, editor: &Editor, mouse: MouseEvent) -> bool { let Some(kind) = wheel_kind(&mouse) else { return false; }; if editor.palette().is_some() || editor.startup_diagnostic().is_some() || editor.command_output().is_some() { return true; } let Some((buffer_id, viewport)) = self.document.geometry else { return false; }; if buffer_id != editor.buffer_id() || viewport.width == 0 || viewport.height == 0 || !viewport.contains((mouse.column, mouse.row).into()) { return false; } let (top, left) = self.offsets.entry(buffer_id).or_default(); if editor.word_wrap() { if matches!( kind, MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight ) { return true; } let continuation = self.document.continuations.entry(buffer_id).or_default(); let mut position = VisualRow { source: *top, continuation: *continuation, }; for _ in 0..3 { let next = if kind == MouseEventKind::ScrollDown { next_visual(&mut self.document.wrap, editor.lines(), position) } else { previous_visual(&mut self.document.wrap, editor.lines(), position) }; let Some(next) = next else { break }; position = next; } position = clamp_visual_bottom( &mut self.document.wrap, editor.lines(), position, usize::from(viewport.height), ); *top = position.source; *continuation = position.continuation; } else if matches!( kind, MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight ) { let key = (buffer_id, editor.text_revision()); let columns = match self.document.horizontal_extent { Some((id, revision, columns)) if (id, revision) == key => columns, _ => { let columns = editor .lines() .iter() .map(|line| display_column(line, usize::MAX)) .max() .unwrap_or(0); self.document.horizontal_extent = Some((key.0, key.1, columns)); columns } }; *left = if kind == MouseEventKind::ScrollRight { left.saturating_add(3) } else { left.saturating_sub(3) } .min( columns .saturating_add(1) .saturating_sub(self.document.text_width.max(1)), ); } else { let last_page = editor .lines() .len() .saturating_sub(usize::from(viewport.height)); *top = if kind == MouseEventKind::ScrollDown { top.saturating_add(3) } else { top.saturating_sub(3) } .min(last_page); } if self.document.manual.is_none() { self.document.manual = Some(ManualViewport { buffer_id, revision: editor.text_revision(), caret_reveal_revision: editor.caret_reveal_revision(), mode: editor.mode(), cursor: editor.cursor(), carets: editor.carets(), selections: editor.selections(), }); } true } fn scroll_ready( &mut self, session: &mut EditorSession, mut mouse: MouseEvent, mut next: impl FnMut() -> io::Result>, ) -> io::Result<(bool, Option)> { let mut redraw = false; // Bound input work so drawing and background replies cannot starve. for index in 0..64 { redraw |= self.scroll(session, mouse); if index == 63 { break; } match next()? { Some(Event::Mouse(report)) => mouse = report, pending => return Ok((redraw, pending)), } } Ok((redraw, None)) } } fn wheel_kind(mouse: &MouseEvent) -> Option { Some(match mouse.kind { MouseEventKind::ScrollUp if mouse.modifiers.contains(KeyModifiers::SHIFT) => { MouseEventKind::ScrollLeft } MouseEventKind::ScrollDown if mouse.modifiers.contains(KeyModifiers::SHIFT) => { MouseEventKind::ScrollRight } kind @ (MouseEventKind::ScrollUp | MouseEventKind::ScrollDown | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight) => kind, _ => return None, }) } fn draw_frame( terminal: &mut Terminal>>, output: &mut W, clear_screen: bool, render: impl FnOnce(&mut Frame), ) -> io::Result<()> { // Do not suspend the terminal's presentation while computing the next frame. terminal.backend_mut().writer_mut().clear(); crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; // Terminals without synchronized updates still hide the painting cursor. let result = terminal.hide_cursor().and_then(|()| { if clear_screen { // Fresh Ratatui buffers are already empty; avoid its blocking cursor query. crossterm::queue!( terminal.backend_mut(), crossterm::terminal::Clear(crossterm::terminal::ClearType::All) )?; } terminal.draw(render).map(|_| ()) }); let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate); result.and(finish)?; output.write_all(terminal.backend().writer())?; output.flush() } /// Renders inside an active `TerminalSession`; inherited contents are cleared /// inside the first synchronized frame rather than exposing a blank screen. pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> { let mut output: Stdout = io::stdout(); let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?; let mut viewports = BufferViewports::default(); let mut colors = TerminalColors::default(); let mut query_theme = true; let mut redraw = true; let mut pending = None; while !session.editor().should_quit() { redraw |= session.poll(); colors.expire(Instant::now()); if viewports.theme != colors.colors { viewports.set_theme(colors.colors); redraw = true; } if redraw { draw_frame(&mut terminal, &mut output, clear_screen, |frame| { viewports.draw_session(frame, session) })?; redraw = false; clear_screen = false; } // Start the reply deadline after painting, not before expensive syntax initialization. if query_theme || viewports.diff_visible { let now = Instant::now(); if query_theme || colors.refresh_due(now) { colors.query(&mut output, now)?; query_theme = false; } } let input = if let Some(input) = pending.take() { input } else { let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))? else { continue; }; input }; match input { Event::Key(key) if key.kind != KeyEventKind::Release => { if let Some(input) = translate(key) { viewports.panels.follow_files(); if batch_key(session.editor(), &key, &input) { pending = keys_ready(session, input, || { next_terminal_event(&mut colors, Duration::ZERO) })?; } else { session.handle(input); } redraw = true; } } Event::Paste(text) => { viewports.panels.follow_files(); session.handle(Input::Paste(text)); redraw = true; } Event::Mouse(mouse) => { let (changed, following) = viewports.scroll_ready(session, mouse, || { next_terminal_event(&mut colors, Duration::ZERO) })?; redraw |= changed; pending = following; } Event::Resize(_, _) => redraw = true, Event::FocusGained => { redraw |= session.refresh_blame(); redraw |= session.refresh_gutter(); query_theme = true; } Event::ColorSchemeChanged => query_theme = true, _ => {} } } Ok(()) } fn next_terminal_event( colors: &mut TerminalColors, mut timeout: Duration, ) -> io::Result> { colors.expire(Instant::now()); if let Some(input) = colors.pop() { return Ok(Some(input)); } while event::poll(timeout)? { colors.push(event::read()?, Instant::now()); if let Some(input) = colors.pop() { return Ok(Some(input)); } timeout = Duration::ZERO; } colors.expire(Instant::now()); Ok(colors.pop()) } fn typing_key(key: &KeyEvent) -> bool { key.kind != KeyEventKind::Release && matches!(key.code, KeyCode::Char(ch) if !ch.is_control()) && !key.modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META, ) } fn batch_key(editor: &Editor, key: &KeyEvent, input: &Input) -> bool { if editor.mode() == Mode::Insert { typing_key(key) } else { key.kind != KeyEventKind::Release && editor.can_batch_navigation(input) } } // Coalesce frames, not actions: bindings, selections and history still see each key. fn keys_ready( session: &mut EditorSession, mut input: Input, mut next: impl FnMut() -> io::Result>, ) -> io::Result> { let mode = session.editor().mode(); // Render before reversing/clamping a scroll or revealing a moved caret. let scroll = session.editor().viewport_scroll_action(&input); for handled in 1..=64 { session.handle(input); if handled == 64 || session.editor().mode() != mode || session.editor().should_quit() { return Ok(None); } let pending = next()?; if let Some(Event::Key(key)) = &pending && let Some(next) = translate(*key) && batch_key(session.editor(), key, &next) && session.editor().viewport_scroll_action(&next) == scroll { input = next; } else { return Ok(pending); } } unreachable!() } fn translate(key: KeyEvent) -> Option { #[cfg(target_os = "macos")] if key.code == KeyCode::Backspace { match key.modifiers { KeyModifiers::ALT => return Some(Input::AltBackspace), KeyModifiers::SUPER => return Some(Input::SuperBackspace), _ => {} } } if key .modifiers .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) { return None; } if key.modifiers.contains(KeyModifiers::ALT) { return match key.code { KeyCode::Char(c) => Some(if key.modifiers.contains(KeyModifiers::CONTROL) { Input::CtrlAlt(c.to_ascii_lowercase()) } else { Input::Alt(c.to_ascii_lowercase()) }), _ => None, }; } Some(match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => { Input::Ctrl(c.to_ascii_lowercase()) } KeyCode::Char(c) => Input::Char(c), KeyCode::Esc => Input::Escape, KeyCode::Enter => Input::Enter, KeyCode::Backspace => Input::Backspace, KeyCode::Delete => Input::Delete, KeyCode::Left => Input::Left, KeyCode::Right => Input::Right, KeyCode::Up => Input::Up, KeyCode::Down => Input::Down, KeyCode::Home => Input::Home, KeyCode::End => Input::End, KeyCode::Tab if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl('i'), KeyCode::Tab => Input::Char('\t'), KeyCode::BackTab => Input::BackTab, _ => return None, }) } fn display_column(line: &str, grapheme_column: usize) -> usize { line.graphemes(true) .take(grapheme_column) .fold(0, |column, grapheme| { column + if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) } }) } fn visible_line(line: &str, left: usize, width: usize) -> String { visible_text(line, left, width, false) } fn visible_text(line: &str, left: usize, width: usize, sanitize_controls: bool) -> String { let mut visible = String::new(); let mut column = 0; let right = left.saturating_add(width); for grapheme in line.graphemes(true) { let control = sanitize_controls && grapheme.chars().any(|ch| ch.is_control() && ch != '\t'); let cells = if control { grapheme.chars().count() } else if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; if column >= right { break; } if end > left || (cells == 0 && column >= left) { if grapheme == "\t" || column < left || end > right { for _ in column.max(left)..end.min(right) { visible.push(' '); } } else if control { for _ in 0..cells { visible.push('�'); } } else { visible.push_str(grapheme); } } column = end; } visible } fn diagnostic_severity(severity: DiagnosticSeverity) -> (usize, Color, &'static str) { match severity { DiagnosticSeverity::Error => (0, Color::Red, "E"), DiagnosticSeverity::Warning => (1, Color::Yellow, "W"), DiagnosticSeverity::Information => (2, Color::Cyan, "I"), DiagnosticSeverity::Hint => (3, Color::DarkGray, "H"), } } fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool { diagnostic.start.row <= row && row <= diagnostic.end.row && (row < diagnostic.end.row || diagnostic.end.column > 0 || diagnostic.start == diagnostic.end) } struct ChromeItem<'a> { text: Cow<'a, str>, config: &'a SegmentConfig, tab: Option, accent: bool, bold: bool, first: bool, mandatory: bool, width: usize, compact: bool, } impl ChromeItem<'_> { fn leading_edge(&self) -> &str { if self.first { self.config .first_leading_edge .as_deref() .unwrap_or(&self.config.leading_edge) } else { &self.config.leading_edge } } } fn display_path(path: &std::path::Path) -> Cow<'_, str> { static HOME: LazyLock> = LazyLock::new(|| { std::env::var_os("HOME") .filter(|home| !home.is_empty()) .or_else(|| std::env::var_os("USERPROFILE")) .map(std::path::PathBuf::from) .filter(|home| { home.is_absolute() && home .to_str() .is_some_and(|home| !home.chars().any(char::is_control)) }) }); abbreviate_home(path, HOME.as_deref()) } fn abbreviate_home<'a>(path: &'a std::path::Path, home: Option<&std::path::Path>) -> Cow<'a, str> { if let Some(suffix) = home.and_then(|home| path.strip_prefix(home).ok()) { return if suffix.as_os_str().is_empty() { Cow::Borrowed("~") } else { Cow::Owned(format!("~/{}", suffix.display())) }; } path.to_string_lossy() } fn chrome_title( template: &str, index: usize, path: Option<&std::path::Path>, dirty: bool, diff: Option<&DiffState>, ) -> String { let name = path .map(|path| { path.file_name() .unwrap_or(path.as_os_str()) .to_string_lossy() }) .unwrap_or(Cow::Borrowed("[No Name]")); let path = path.map(display_path).unwrap_or(Cow::Borrowed("[No Name]")); let mut title = String::with_capacity(template.len() + name.len()); let mut rest = template; while let Some((literal, placeholder)) = rest.split_once('{') { title.push_str(literal); let Some((field, after)) = placeholder.split_once('}') else { break; }; match field { "index" => write!(title, "{index}").unwrap(), "view" if diff.is_some() => { title.push_str("Diff · "); if let Some(diff) = diff && diff.phase == Phase::View && let Some(file) = diff .comparison .as_ref() .and_then(|comparison| comparison.files.get(diff.file)) && file.loaded { let (added, removed) = file.hunks.iter().fold((0, 0), |(added, removed), hunk| { (added + hunk.after_len, removed + hunk.before_len) }); write!(title, "+{added} -{removed} · ").unwrap(); } } "name" => title.push_str(&name), "path" => title.push_str(&path), "modified" if dirty => title.push_str(" [+]"), _ => {} } rest = after; } title.push_str(rest); if title.chars().any(char::is_control) { display_text(&title) } else { title } } fn chrome_items<'a>( editor: &'a Editor, line: &'a ChromeLine, names: &[String], syntax: &SyntaxCache, ) -> Vec> { let mut items = Vec::new(); for name in names { let config = line.segment(name); if name == "tabs" { for index in 0..editor.tab_count() { let buffer = editor.tab_buffer(index); let active = index == editor.tab_index(); items.push(ChromeItem { text: Cow::Owned(chrome_title( &config.title, index + 1, buffer.as_ref().and_then(|buffer| buffer.path), buffer.is_some_and(|buffer| buffer.dirty), editor.tab_diff_at(index), )), config, tab: Some(index), accent: active, bold: active, first: index == 0, mandatory: active, width: 0, compact: false, }); } continue; } let mut text = match name.as_str() { "mode" => Cow::Owned( if editor.mode() == Mode::Visual && editor.visual_linewise() { "VISUAL LINE".into() } else { editor.mode().to_string() }, ), "path" => Cow::Owned(chrome_title( "{path}{modified}", editor.tab_index() + 1, editor.path(), editor.dirty(), editor.tab_diff_at(editor.tab_index()), )), "position" => Cow::Owned(format!( "{}:{}", editor.cursor().row + 1, editor.cursor().column + 1 )), "language" => Cow::Owned(if let Some(error) = syntax.error() { format!("syntax: {error}") } else { syntax.language().unwrap_or_default().to_owned() }), "lsp" => Cow::Borrowed(editor.lsp_label()), "diagnostics" => { let mut counts = [0; 4]; for diagnostic in editor.diagnostics() { counts[diagnostic_severity(diagnostic.severity).0] += 1; } let mut text = String::new(); for (label, count) in ["E", "W", "I", "H"].into_iter().zip(counts) { if count > 0 { if !text.is_empty() { text.push_str(" "); } write!(text, "{label}:{count}").unwrap(); } } Cow::Owned(text) } "selections" => Cow::Owned(if editor.selection_count() > 1 { format!("{} selections", editor.selection_count()) } else { String::new() }), "count" => Cow::Owned( editor .count() .map(|count| format!("count: {count}")) .unwrap_or_default(), ), "pending" => { let mut text = editor.sequence_pending().unwrap_or_default().to_owned(); for pending in [ editor.g_pending().then_some('g'), editor.line_operator_pending(), editor.seek_pending(), ] .into_iter() .flatten() { if !text.is_empty() { text.push_str(" "); } text.push(pending); } Cow::Owned(text) } "buffer" => Cow::Owned(if editor.buffer_count() > 1 { format!("[buffer {}/{}]", editor.buffer_id(), editor.buffer_count()) } else { String::new() }), "battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()), "shell" if editor.label(name).is_empty() => Cow::Borrowed(editor.shell_label()), _ => Cow::Borrowed(editor.label(name)), }; // Empty values remain hidden even when a title or decoration is configured. if text.is_empty() { continue; } if !config.title.is_empty() { text = Cow::Owned(chrome_title( &config.title, editor.tab_index() + 1, editor.path(), editor.dirty(), editor.tab_diff_at(editor.tab_index()), )); } else if text.chars().any(char::is_control) { text = Cow::Owned(display_text(&text)); } items.push(ChromeItem { text, config, tab: None, accent: !matches!( name.as_str(), "path" | "language" | "diagnostics" | "selections" | "count" | "pending" | "buffer" ), bold: false, first: true, mandatory: false, width: 0, compact: false, }); } for item in &mut items { item.width = item.text.width() + item.config.padding * 2 + item.config.margin[0] + item.config.margin[1] + if item.tab.is_none() || item.accent { item.leading_edge().width() + item.config.trailing_edge.width() } else { 0 }; } items.retain(|item| item.width > 0); items } fn chrome_group_width(items: &[ChromeItem<'_>]) -> usize { items.iter().map(|item| item.width).sum::() + items.len().saturating_sub(1) } fn chrome_fits([left, center, right]: [usize; 3], width: usize) -> bool { if left > width || center > width || right > width { return false; } let center_start = (width - center) / 2; let right_start = width - right; if center > 0 { (left == 0 || left < center_start) && (right == 0 || center_start + center < right_start) } else { left == 0 || right == 0 || left < right_start } } fn draw_chrome(frame: &mut Frame, mut groups: [Vec>; 3], area: Rect) { if area.width == 0 || area.height == 0 { return; } let mut active_seen = false; for item in groups.iter_mut().flatten().filter(|item| item.mandatory) { item.mandatory = !active_seen; active_seen = true; } if !groups.iter().flatten().any(|item| item.mandatory) && let Some(item) = groups.iter_mut().flatten().next() { item.mandatory = true; } let active = groups .iter() .flatten() .find(|item| item.mandatory) .and_then(|item| item.tab); let mut widths = groups.each_ref().map(|group| chrome_group_width(group)); let width = usize::from(area.width); while !chrome_fits(widths, width) { // Inactive tabs yield before configured values, farthest from the active tab first. let inactive = groups .iter() .enumerate() .flat_map(|(group, items)| { items.iter().enumerate().filter_map(move |(index, item)| { item.tab .filter(|_| !item.mandatory) .map(|tab| (tab.abs_diff(active.unwrap_or(tab)), group, index)) }) }) .max(); let removable = inactive .map(|(_, group, index)| (group, index)) .or_else(|| { [1, 2, 0].into_iter().find_map(|group| { groups[group] .iter() .rposition(|item| !item.mandatory) .map(|index| (group, index)) }) }); let Some((group, index)) = removable else { for (group, items) in groups.iter_mut().enumerate() { for item in items { let content_width = item.text.width().min(width); widths[group] = widths[group] - item.width + content_width; item.width = content_width; item.compact = true; } } break; }; groups[group].remove(index); widths[group] = chrome_group_width(&groups[group]); } let widths = widths.map(|cells| cells.min(width)); let starts = [0, (width - widths[1]) / 2, width - widths[2]]; for (items, start) in groups.iter().zip(starts) { let mut x = start; for (index, item) in items.iter().enumerate() { x += usize::from(index > 0); if x >= width { break; } let available = width - x; let highlight = Style::default().fg(Color::Black).bg(Color::Cyan); let highlight = if item.bold { highlight.add_modifier(Modifier::BOLD) } else { highlight }; let style = if item.accent { highlight } else if item.tab.is_some() { Style::default().fg(Color::Gray) } else { Style::default() }; let item_width = item.width; let line = if item.compact || item_width > available { // Content wins over margins, padding, and fades on tiny terminals. Line::from(Span::styled(visible_line(&item.text, 0, available), style)) } else { const PADDING: &str = " "; let padding = &PADDING[..item.config.padding]; let leading_margin = &PADDING[..item.config.margin[0]]; let trailing_margin = &PADDING[..item.config.margin[1]]; let edges = item.tab.is_none() || item.accent; let edge = Style::default().fg(Color::Cyan); Line::from(vec![ Span::styled(leading_margin, Style::reset()), Span::styled(if edges { item.leading_edge() } else { "" }, edge), Span::styled(padding, style), Span::styled(item.text.as_ref(), style), Span::styled(padding, style), Span::styled( if edges { &item.config.trailing_edge } else { "" }, edge, ), Span::styled(trailing_margin, Style::reset()), ]) }; let cells = item_width.min(available); frame.render_widget( Paragraph::new(line), Rect::new(area.x.saturating_add(x as u16), area.y, cells as u16, 1), ); x += cells; } } } fn chrome_groups<'a>( editor: &'a Editor, line: &'a ChromeLine, syntax: &SyntaxCache, ) -> [Vec>; 3] { [&line.left, &line.center, &line.right].map(|names| chrome_items(editor, line, names, syntax)) } fn chrome_visible(editor: &Editor, groups: &[Vec>; 3]) -> bool { groups.iter().flatten().any(|item| item.tab.is_none()) || (editor.tab_count() > 1 && groups.iter().any(|group| !group.is_empty())) } fn document_layout(area: Rect, visible: [bool; 2]) -> [Rect; 3] { // Bars yield to the last document row, identically at either edge. let available = area.height.saturating_sub(1); let top = u16::from(visible[0]).min(available); let bottom = u16::from(visible[1]).min(available - top); let workspace = Rect::new( area.x, area.y.saturating_add(top), area.width, area.height - top - bottom, ); [ Rect::new(area.x, area.y, area.width, top), workspace, Rect::new(area.x, workspace.bottom(), area.width, bottom), ] } fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut SyntaxCache) -> Rect { if area.width == 0 || area.height == 0 { return area; } // Resolve the active buffer's language before computing either bar's visibility. syntax.update_visible(editor.lines(), editor.path(), editor.text_revision(), &[]); let groups = [ chrome_groups(editor, editor.top_bar_config(), syntax), chrome_groups(editor, editor.bottom_bar_config(), syntax), ]; let visible = groups .each_ref() .map(|groups| chrome_visible(editor, groups)); let [top, workspace, bottom] = document_layout(area, visible); for (groups, area) in groups.into_iter().zip([top, bottom]) { draw_chrome(frame, groups, area); } workspace } struct ResolverHistoryLayout { heading: Rect, history: Vec<(usize, Rect)>, } fn resolver_history_layout( area: Rect, resolver: &ResolverWorkspace, focused: usize, mode: ResolverLayout, ) -> ResolverHistoryLayout { let [heading, documents] = Layout::vertical([ Constraint::Length(u16::from(area.height >= 4)), Constraint::Fill(1), ]) .areas(area); let mut layout = ResolverHistoryLayout { heading, history: Vec::with_capacity(3), }; if documents.is_empty() { return layout; } let Some(file) = resolver.files.get(resolver.current_file) else { return layout; }; let mut sources = Vec::with_capacity(3); for index in [ Some(resolver.history_sources[0]), resolver.selected_base, Some(resolver.history_sources[1]), ] .into_iter() .flatten() { if index < file.sources.len() && !sources.contains(&index) { sources.push(index); } } let base_below = mode == ResolverLayout::BaseBelow && sources.len() == 3; if documents.width < 60 || (base_below && documents.height < 4) { let selected = sources .iter() .copied() .find(|index| resolver.history_panes.get(*index) == Some(&focused)) .or_else(|| sources.first().copied()); if let Some(selected) = selected { layout.history.push((selected, documents)); } } else if base_below { let [inputs, base] = Layout::vertical([Constraint::Fill(1); 2]).areas(documents); let [left, right] = Layout::horizontal([Constraint::Fill(1); 2]).areas(inputs); layout .history .extend([(sources[0], left), (sources[2], right), (sources[1], base)]); } else { let columns = Layout::horizontal(vec![Constraint::Fill(1); sources.len()]).split(documents); layout .history .extend(sources.into_iter().zip(columns.iter().copied())); } layout } fn resolver_status(status: &ResolverStatus) -> (&'static str, Color) { match status { ResolverStatus::Unresolved => ("unresolved", Color::Yellow), ResolverStatus::Unsaved => ("edited · unsaved", Color::Yellow), ResolverStatus::Checking => ("saved · checking jj", Color::Cyan), ResolverStatus::Resolved => ("resolved", Color::Green), ResolverStatus::Unconfirmed => ("unconfirmed · jj check failed", Color::Red), } } fn draw_resolver_heading(frame: &mut Frame, resolver: &ResolverWorkspace, heading: Rect) { let file = resolver.files.get(resolver.current_file); let status = if resolver.loading { ("loading", Color::Cyan) } else { file.map_or(("no conflicted files", Color::Green), |file| { resolver_status(&file.status) }) }; let mut spans = vec![ Span::styled( format!( "{} · {} ", if resolver.view == ResolverView::History { "History" } else { "Resolution" }, status.0 ), Style::default().fg(status.1), ), Span::raw(file.map_or_else(|| Cow::Borrowed(""), |file| display_path(&file.path))), ]; if let Some(error) = resolver .error .as_deref() .or_else(|| file.and_then(|file| file.error.as_deref())) { spans.push(Span::styled( format!(" · {}", display_text(error)), Style::default().fg(Color::Red), )); } frame.render_widget(Paragraph::new(Line::from(spans)), heading); } fn resolver_document_panel(frame: &mut Frame, panel: Rect, title: &str, focused: bool) -> Rect { let [heading, document] = Layout::vertical([ Constraint::Length(u16::from(panel.height >= 2)), Constraint::Fill(1), ]) .areas(panel); frame.render_widget( Paragraph::new(display_text(title)).style( Style::default() .fg(if focused { Color::Cyan } else { Color::DarkGray }) .add_modifier(Modifier::BOLD), ), heading, ); document } fn draw_resolver_choices(frame: &mut Frame, resolver: &ResolverWorkspace, area: Rect) { let Some(file) = resolver.files.get(resolver.current_file) else { return; }; let (status, color) = resolver_status(&file.status); let mut lines = vec![ Line::from(format!( "Whole-file conflict · {status} · text editing is unavailable" )) .style(Style::default().fg(color)), Line::from( file.whole_file_choice .and_then(|index| file.sources.get(index)) .map_or_else( || "No whole-file choice selected.".to_owned(), |source| format!("Selected: {}", display_text(&source.label)), ), ), Line::from("Choose an explicit input, then :w to save and check jj."), ]; lines.extend( file.sources .iter() .enumerate() .filter(|(_, source)| !source.base) .map(|(index, source)| { Line::from(format!( "{} :resolve.use_source {} · {}", if file.whole_file_choice == Some(index) { ">" } else { " " }, index + 1, display_text(&source.label), )) }), ); frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); } #[allow(clippy::too_many_arguments)] fn draw_document( frame: &mut Frame, editor: &Editor, workspace: Rect, overlay_area: Rect, top: &mut usize, left: &mut usize, syntax: &mut SyntaxCache, document: &mut DocumentViewport, panels: &mut WheelPanels, ) { panels.begin_frame(); document.geometry = None; let area = workspace; let focused = editor.render_pane_focused(); if let Some(diff) = editor.diff() { if panels.workspace_managed && diff.phase != Phase::View { return; } draw_diff( frame, diff, area, editor.selection_navigation_hint(), editor.file_navigation_hint(), panels, Some(editor), ); return; } panels.cached_diff_error(None); if overlay_area.width == 0 || overlay_area.height == 0 { return; } let (viewport, dock) = match editor.file_picker() { Some(picker) if !panels.workspace_managed && focused && picker.presentation == FilePresentation::Docked => { dock_layout(workspace, editor.file_explorer_side(), picker.focused) } _ => (workspace, None), }; let digits = editor.lines().len().ilog10() as usize + 1; let marker_gutter = matches!( editor.gutter_style(), crate::gutter::GutterStyle::Bar | crate::gutter::GutterStyle::Symbols ); let gutter_width = (digits + 1 + 2 * usize::from(marker_gutter)) as u16; let [gutter, viewport] = Layout::horizontal([ Constraint::Length(if viewport.width > gutter_width { gutter_width } else { 0 }), Constraint::Fill(1), ]) .areas(viewport); let height = usize::from(viewport.height); let width = usize::from(viewport.width); document.text_width = width; let cursor = editor.cursor(); let mut document_caret = None; let carets = editor.carets(); let selections = editor.selections(); let wrapped = editor.word_wrap(); let presentation = (editor.buffer_id(), wrapped, width, height); let identity = (editor.buffer_id(), editor.text_revision(), cursor); if document.anchor != Some(identity) || document .presentation .is_some_and(|previous| previous.2 != width || previous.3 != height) { document.anchor = None; } let toggle_row = document .presentation .filter(|previous| previous.0 == editor.buffer_id() && previous.1 != wrapped) .and_then(|previous| { let column = display_column(&editor.lines()[cursor.row], cursor.column); let part = if previous.1 { caret_segment( document .wrap .segments(cursor.row, &editor.lines()[cursor.row]), column, ) } else { 0 }; document .visible .iter() .position(|&(row, continuation, segment)| { row == cursor.row && continuation == part && (previous.1 || (column >= segment.start && column < segment.end)) }) }) .map(|row| row.min(height.saturating_sub(1))); let center_row = (height > 0 && width > 0 && editor.take_lsp_center()).then_some(height / 2); let toggle_row = center_row.or(toggle_row); if center_row.is_some() { document.manual = None; } if toggle_row.is_some() { document.anchor = Some(identity); } if document.presentation.is_some_and(|previous| { previous.0 != presentation.0 || previous.1 != wrapped || (wrapped && previous != presentation) }) { document.manual = None; } document.presentation = Some(presentation); document.visible.clear(); if !focused && let Some(manual) = &mut document.manual { // Shared text edits alone must not pull an untouched pane back to its caret. manual.revision = editor.text_revision(); manual.mode = editor.mode(); manual.cursor = cursor; if manual.carets != carets { manual.carets.clone_from(&carets); } if manual.selections != selections { manual.selections.clone_from(&selections); } } if document.manual.as_ref().is_some_and(|manual| { manual.buffer_id != editor.buffer_id() || manual.revision != editor.text_revision() || manual.caret_reveal_revision != editor.caret_reveal_revision() || manual.mode != editor.mode() || manual.cursor != cursor || manual.carets != carets || manual.selections != selections }) { document.manual = None; } document.geometry = Some(( editor.buffer_id(), Rect::new( gutter.x, viewport.y, gutter.width + viewport.width, viewport.height, ), )); if !wrapped { let limit = if document.anchor.is_some() || editor.scroll_distance() != Some(0) { editor.lines().len() - 1 } else { editor.lines().len().saturating_sub(height) }; *top = (*top).min(limit); if let Some(row) = toggle_row { *top = cursor.row.saturating_sub(row); } if document.manual.is_some() && let Some((id, revision, columns)) = document.horizontal_extent && id == editor.buffer_id() && revision == editor.text_revision() { *left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1))); } } if height > 0 && width > 0 { let scroll = editor.take_viewport_scroll(height); if scroll != 0 { document.anchor = None; document.manual = Some(ManualViewport { buffer_id: editor.buffer_id(), revision: editor.text_revision(), caret_reveal_revision: editor.caret_reveal_revision(), mode: editor.mode(), cursor, carets: carets.clone(), selections: selections.clone(), }); } if wrapped { document .wrap .prepare((editor.buffer_id(), editor.text_revision()), width); let continuation = document .continuations .entry(editor.buffer_id()) .or_default(); *top = (*top).min(editor.lines().len() - 1); *continuation = (*continuation).min(document.wrap.segments(*top, &editor.lines()[*top]).len() - 1); let mut start = VisualRow { source: *top, continuation: *continuation, }; for _ in 0..scroll.unsigned_abs() { let next = if scroll > 0 { next_visual(&mut document.wrap, editor.lines(), start) } else { previous_visual(&mut document.wrap, editor.lines(), start) }; let Some(next) = next else { break }; start = next; } if document.manual.is_none() { let column = display_column(&editor.lines()[cursor.row], cursor.column); let caret = VisualRow { source: cursor.row, continuation: caret_segment( document .wrap .segments(cursor.row, &editor.lines()[cursor.row]), column, ), }; let margin = scroll_margin(height, editor.scroll_distance()); let anchor = toggle_row.or_else(|| { if margin == height / 2 { return Some(margin); } let (first, count) = visual_page(&mut document.wrap, editor.lines(), start, margin + 1); let last = visual_page(&mut document.wrap, editor.lines(), start, height - margin).0; if caret < first || count <= margin { Some(margin) } else if caret > last { Some(height - margin - 1) } else { None } }); if let Some(row) = anchor { start = caret; for _ in 0..row { let Some(previous) = previous_visual(&mut document.wrap, editor.lines(), start) else { break; }; start = previous; } } } if document.anchor.is_none() && editor.scroll_distance() == Some(0) { start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height); } *top = start.source; *continuation = start.continuation; let mut position = start; for _ in 0..height { let segment = document .wrap .segments(position.source, &editor.lines()[position.source]) [position.continuation]; document .visible .push((position.source, position.continuation, segment)); let Some(next) = next_visual(&mut document.wrap, editor.lines(), position) else { break; }; position = next; } } else { if scroll != 0 { *top = top .saturating_add_signed(scroll) .min(editor.lines().len().saturating_sub(height)); } if document.manual.is_none() { if toggle_row.is_none() { *top = scroll_offset(*top, cursor.row, height, editor.scroll_distance()); } let column = display_column(&editor.lines()[cursor.row], cursor.column); *left = scroll_offset(*left, column, width, editor.scroll_distance()); } document.visible.extend( (*top..top.saturating_add(height).min(editor.lines().len())).map(|row| { ( row, 0, Segment { start: *left, end: left.saturating_add(width), }, ) }), ); } let last = document.visible.last().map_or(*top, |(row, _, _)| *row); syntax.update_visible( editor.lines(), editor.path(), editor.text_revision(), std::slice::from_ref(&(*top..last + 1)), ); if gutter.width > 0 { let numbers: Vec = document .visible .iter() .map(|&(row, continuation, _)| { if continuation > 0 { return Line::default(); } let style = if row == cursor.row { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; let diagnostic = editor .diagnostics() .filter(|diagnostic| diagnostic_on_row(diagnostic, row)) .map(|diagnostic| diagnostic_severity(diagnostic.severity)) .min_by_key(|(severity, _, _)| *severity); let style = match diagnostic { Some((_, color, _)) => style.fg(color), None => style, }; let number = if row == cursor.row { row + 1 } else { row.abs_diff(cursor.row) }; let change = editor.gutter_change(row); let color = change.map(|change| match change { crate::gutter::Change::Added => Color::Green, crate::gutter::Change::Removed => Color::Red, crate::gutter::Change::Modified => Color::Yellow, }); let style = if editor.gutter_style() == crate::gutter::GutterStyle::LineNumbers { if row == cursor.row { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { color .filter(|_| diagnostic.is_none()) .map_or(style, |color| style.fg(color)) } } else { style }; let mut spans = vec![Span::styled(format!("{number:>digits$}"), style)]; if marker_gutter { spans.push(Span::raw(" ")); let marker = match (editor.gutter_style(), change) { (crate::gutter::GutterStyle::Bar, Some(_)) => "▌", (_, Some(crate::gutter::Change::Added)) => "+", (_, Some(crate::gutter::Change::Removed)) => "-", (_, Some(crate::gutter::Change::Modified)) => "~", (_, None) => " ", }; spans.push(Span::styled( marker, Style::default().fg(color.unwrap_or(Color::DarkGray)), )); } spans.push(Span::raw(" ")); Line::from(spans) }) .collect(); frame.render_widget(Paragraph::new(numbers), gutter); } let lines: Vec = document .visible .iter() .map(|&(row, _, segment)| { Line::from(visible_text( &editor.lines()[row], segment.start, segment.end.saturating_sub(segment.start).min(width), false, )) }) .collect(); frame.render_widget(Paragraph::new(lines), viewport); for (screen_row, &(row, continuation, segment)) in document.visible.iter().enumerate() { let line = &editor.lines()[row]; let cells: usize = if wrapped { segment .end .saturating_sub(segment.start) .saturating_add(usize::from( continuation + 1 == document.wrap.segments(row, line).len(), )) .min(width) } else { width }; let area = Rect::new(viewport.x, viewport.y + screen_row as u16, cells as u16, 1); paint_syntax_line(frame, line, syntax.line(row), area, segment.start); paint_semantic_line( frame, line, editor.semantic_tokens(), row, area, segment.start, ); paint_whitespace( frame, line, area, segment.start, editor.whitespace_characters(), editor.whitespace_scope(), syntax.string_spans(row), row + 1 < editor.lines().len(), false, ); if editor.highlight_trailing_whitespace() { paint_trailing_whitespace(frame, line, area, segment.start); } paint_editor_line( frame, editor, row, area, segment.start, &carets, &selections, ); } } if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { let column = display_column(&editor.lines()[cursor.row], cursor.column); let continuation = if wrapped { caret_segment( document .wrap .segments(cursor.row, &editor.lines()[cursor.row]), column, ) } else { 0 }; if let Some((screen_row, (_, _, segment))) = document .visible .iter() .enumerate() .find(|(_, (row, part, segment))| { *row == cursor.row && *part == continuation && column >= segment.start && column < segment.start.saturating_add(width) }) { let position = Position::new( viewport.x + (column - segment.start) as u16, viewport.y + screen_row as u16, ); document_caret = Some(position); } } if focused && document_caret.is_some() && let Some(label) = editor.line_blame() && let Some((screen_row, &(_, continuation, segment))) = document .visible .iter() .enumerate() .rev() .find(|(_, (row, _, _))| *row == cursor.row) { let line = &editor.lines()[cursor.row]; let end = display_column(line, usize::MAX); let start = end.saturating_sub(segment.start).saturating_add(2); let label_width = UnicodeWidthStr::width(label); if end >= segment.start && end <= segment.end && (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len()) && start.saturating_add(label_width) <= width { frame.render_widget( Paragraph::new(label).style(Style::default().fg(Color::DarkGray)), Rect::new( viewport.x + start as u16, viewport.y + screen_row as u16, label_width as u16, 1, ), ); } } if !focused { return; } panels.caret = document_caret.map(|caret| (viewport, caret)); if !panels.workspace_managed { if let Some(panel) = dock && let Some(picker) = editor.file_picker() { panels.picker_scroll.distance = editor.scroll_distance(); draw_file_list( frame, picker, panel, editor.file_navigation_hint(), panels, None, ); } draw_overlays(frame, editor, overlay_area, panels.caret, panels); } } fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option { if area.width == 0 || area.height == 0 { return None; } let width = area.width.min(72); let height = area.height.min(3); let anchor = if editor.mode() == Mode::Search { editor.popup_config().search.anchor } else { editor.popup_config().command.anchor }; let x = area.x + match anchor { PopupAnchor::TopLeft | PopupAnchor::CenterLeft | PopupAnchor::BottomLeft => 0, PopupAnchor::TopCenter | PopupAnchor::Center | PopupAnchor::BottomCenter => { (area.width - width) / 2 } PopupAnchor::TopRight | PopupAnchor::CenterRight | PopupAnchor::BottomRight => { area.width - width } }; let y = area.y + match anchor { PopupAnchor::TopLeft | PopupAnchor::TopCenter | PopupAnchor::TopRight => 0, PopupAnchor::CenterLeft | PopupAnchor::Center | PopupAnchor::CenterRight => { (area.height - height) / 2 } PopupAnchor::BottomLeft | PopupAnchor::BottomCenter | PopupAnchor::BottomRight => { area.height - height } }; let panel = Rect::new(x, y, width, height); let bordered = panel.width >= 4 && panel.height >= 3; let padding = u16::from(panel.width.saturating_sub(2 * u16::from(bordered)) >= 3); let mut block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(Color::Cyan)) .padding(ratatui::widgets::Padding::horizontal(padding)); if bordered { block = block.title(if editor.mode() == Mode::Search { " Search " } else { " Command " }); } let content = block.inner(panel); frame.render_widget(Clear, panel); frame.render_widget(block, panel); let command = editor.command_line(); let column = display_column(command, usize::MAX); let width = usize::from(content.width); let offset = column.saturating_sub(width.saturating_sub(1)); frame.render_widget( Paragraph::new(visible_line(command, offset, width)), content, ); frame.set_cursor_position((content.x + (column - offset) as u16, content.y)); Some(panel) } fn draw_notification(frame: &mut Frame, editor: &Editor, area: Rect) -> Option { if !editor.notification_visible() || area.width == 0 || area.height == 0 { return None; } let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { let (_, _, label) = diagnostic_severity(diagnostic.severity); let diagnostic = &diagnostic.message; Cow::Owned(if editor.message().is_empty() { format!("{label}: {diagnostic}") } else { format!("{} | {label}: {diagnostic}", editor.message()) }) } else { Cow::Borrowed(editor.message()) }; if message.is_empty() { return None; } let message = if message.chars().any(char::is_control) { Cow::Owned(display_text(&message)) } else { message }; let width = (UnicodeWidthStr::width(message.as_ref()).saturating_add(if area.height >= 3 { 4 } else { 2 })) .min(60) .min(usize::from(area.width)) as u16; let bordered = width >= 4 && area.height >= 3; let border = 2 * u16::from(bordered); let padding = u16::from(width.saturating_sub(border) >= 3); let paragraph = Paragraph::new(message).wrap(Wrap { trim: false }); let height = (paragraph.line_count(width - border - 2 * padding).min(6) as u16 + border) .min(area.height); let panel = Rect::new(area.right() - width, area.bottom() - height, width, height); let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(Color::DarkGray)) .padding(ratatui::widgets::Padding::horizontal(padding)); let content = block.inner(panel); frame.render_widget(Clear, panel); frame.render_widget(block, panel); frame.render_widget(paragraph, content); Some(panel) } fn draw_overlays( frame: &mut Frame, editor: &Editor, area: Rect, caret: Option<(Rect, Position)>, panels: &mut WheelPanels, ) { panels.picker_scroll.distance = editor.scroll_distance(); if editor.completion().is_none() { panels.picker_scroll.completion = None; } if editor.palette().is_none() { panels.picker_scroll.palette = None; } if editor.diff().is_none_or(|diff| diff.phase == Phase::View) { panels.picker_scroll.revisions = None; } panels.input = None; panels.notification = None; let covered = editor.completion().is_some() || editor.palette().is_some() || editor.hover().is_some() || editor.startup_diagnostic().is_some() || editor.command_output().is_some() || editor .file_picker() .is_some_and(|picker| picker.presentation == FilePresentation::Floating); if !covered { if matches!(editor.mode(), Mode::Command | Mode::Search) { panels.input = draw_floating_input(frame, editor, area); } else if editor.diff().is_none_or(|diff| diff.phase == Phase::View) && !editor.file_picker().is_some_and(|picker| picker.focused) { panels.notification = draw_notification(frame, editor, area); } } if !matches!(editor.mode(), Mode::Command | Mode::Search) && editor.command_output().is_none() && !editor.file_picker().is_some_and(|picker| picker.focused) && let Some((_, position)) = caret && !panels .notification .is_some_and(|panel| panel.contains(position)) { frame.set_cursor_position(position); } if let Some(completion) = editor.completion() && let Some((_, caret)) = caret { panels.completion = draw_completion(frame, completion, area, caret, panels); } if let Some(picker) = editor.file_picker() && picker.presentation == FilePresentation::Floating { let panel = floating_panel(area); frame.render_widget(Clear, panel); draw_file_list( frame, picker, panel, editor.file_navigation_hint(), panels, editor.picker_diff().and_then(|diff| { diff.comparison .as_ref() .map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)) }), ); } if let Some(palette) = editor.palette() { draw_palette( frame, palette, area, editor.mode(), editor.selection_navigation_hint(), panels, ); } if let Some(hover) = editor.hover() { draw_hover(frame, hover, area, panels); } if let Some(diagnostic) = editor.startup_diagnostic() { draw_startup_diagnostic(frame, diagnostic, area); } if let Some(output) = editor.command_output() { draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output)); } } struct DiffGeometry { regions: [(Rect, Focus); 3], viewport: Option<(u64, usize, usize, usize, usize)>, text_widths: [u16; 2], wrapping: bool, view_mode: ViewMode, split_axis: SplitAxis, caret_row: Option, } fn diff_panel( frame: &mut Frame, area: Rect, title: &str, state: &str, revision: &str, focused: bool, ) -> Rect { if area.width < 4 || area.height < 4 { return area; } let title_width = usize::from(area.width - 2) .saturating_sub(UnicodeWidthStr::width(state) + usize::from(!state.is_empty())); let title = if UnicodeWidthStr::width(title) > title_width { Cow::Owned(visible_line(title, 0, title_width)) } else { Cow::Borrowed(title) }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if focused { Color::Cyan } else { Color::DarkGray })) .title_top(Line::from(title).left_aligned()) .title_top(Line::from(state).right_aligned()) .title_bottom(Line::from(revision).left_aligned()); let inner = block.inner(area); frame.render_widget(block, area); inner } fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: usize, style: Style) { if area.height == 0 { return; } for column in start.max(left)..end.min(left.saturating_add(usize::from(area.width))) { frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].set_style(style); } } #[allow(clippy::too_many_arguments)] fn paint_whitespace( frame: &mut Frame, line: &str, area: Rect, left: usize, characters: &BTreeMap, scope: WhitespaceScope, strings: &[SyntaxSpan], newline: bool, sanitize_controls: bool, ) { if characters.is_empty() || area.width == 0 || area.height == 0 { return; } let leading_end = match scope { WhitespaceScope::All => line.len(), WhitespaceScope::Leading => line.len() - line.trim_start_matches(char::is_whitespace).len(), }; let right = left.saturating_add(usize::from(area.width)); let style = Style::default() .fg(Color::Reset) .add_modifier(Modifier::DIM); let mut strings = strings.iter().peekable(); let mut column = 0; for (byte, grapheme) in line.grapheme_indices(true) { if column >= right { return; } let control = sanitize_controls && grapheme.chars().any(|ch| ch.is_control() && ch != '\t'); let cells = if control { grapheme.chars().count() } else if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; while strings.peek().is_some_and(|span| span.end_byte <= byte) { strings.next(); } let protected = strings .peek() .is_some_and(|span| span.start_byte < byte + grapheme.len()); if byte < leading_end && !protected && cells > 0 { let ch = grapheme.chars().next().unwrap(); if grapheme.len() == ch.len_utf8() && let Some(&marker) = characters.get(&ch) { // Ratatui resets covered wide-glyph cells; restore their source style when exposed. let wide_style = (cells > 1 && grapheme != "\t" && column >= left) .then(|| frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].style()); for cell in column.max(left)..end.min(right) { let cell = &mut frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)]; cell.set_symbol(" "); if let Some(style) = wide_style { cell.set_style(style); } } if column >= left { frame.buffer_mut()[(area.x + (column - left) as u16, area.y)] .set_char(marker) .set_style(style); } } } column = end; } while strings .peek() .is_some_and(|span| span.end_byte <= line.len()) { strings.next(); } if newline && column >= left && column < right && strings .peek() .is_none_or(|span| span.start_byte > line.len()) && let Some(&marker) = characters.get(&'\n') { frame.buffer_mut()[(area.x + (column - left) as u16, area.y)] .set_char(marker) .set_style(style); } } fn paint_trailing_whitespace(frame: &mut Frame, line: &str, area: Rect, left: usize) { let prefix = line.trim_end_matches(char::is_whitespace); if prefix.len() == line.len() { return; } paint_cells( frame, area, left, display_column(prefix, usize::MAX), display_column(line, usize::MAX), Style::default().bg(Color::Red), ); } fn paint_grapheme_range( frame: &mut Frame, area: Rect, left: usize, line: &str, range: std::ops::Range, style: Style, ) { let right = left.saturating_add(usize::from(area.width)); let mut column = 0; let mut count = 0; for (index, grapheme) in line.graphemes(true).enumerate() { let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let next = column + cells; if range.contains(&index) && (grapheme == "\t" || (column >= left && next <= right)) { paint_cells(frame, area, left, column, next, style); } column = next; count = index + 1; if column >= right { break; } } if range.start <= count && range.end > count { paint_cells(frame, area, left, column, column.saturating_add(1), style); } } fn paint_semantic_line( frame: &mut Frame, line: &str, semantic: &[SemanticToken], row: usize, area: Rect, left: usize, ) { let mut token = semantic.partition_point(|token| token.start.row < row); let mut token_style = None; let mut column = 0; let right = left.saturating_add(usize::from(area.width)); for (index, grapheme) in line.graphemes(true).enumerate() { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; while token < semantic.len() && semantic[token].start.row == row && semantic[token].end.column <= index { token += 1; token_style = None; } if let Some(item) = semantic .get(token) .filter(|token| token.start.row == row && token.start.column <= index) && (grapheme == "\t" || (column >= left && end <= right)) { let style = *token_style.get_or_insert_with(|| semantic_style(item)); paint_cells(frame, area, left, column, end, style); } column = end; } } fn paint_editor_line( frame: &mut Frame, editor: &Editor, row: usize, area: Rect, left: usize, carets: &[Cursor], selections: &[Selection], ) { let Some(line) = editor.lines().get(row) else { return; }; for severity in [ DiagnosticSeverity::Hint, DiagnosticSeverity::Information, DiagnosticSeverity::Warning, DiagnosticSeverity::Error, ] { for diagnostic in editor .diagnostics() .filter(|item| item.severity == severity && diagnostic_on_row(item, row)) { let start = if row == diagnostic.start.row { diagnostic.start.column } else { 0 }; let end = if row == diagnostic.end.row { diagnostic .end .column .saturating_add(usize::from(diagnostic.start == diagnostic.end)) } else { usize::MAX }; let style = Style::default() .underline_color(diagnostic_severity(severity).1) .add_modifier(Modifier::UNDERLINED); paint_grapheme_range(frame, area, left, line, start..end, style); } } for found in editor.search_matches_on_row(row) { paint_cells( frame, area, left, display_column(line, found.start.column), display_column(line, found.end_column), Style::default().fg(Color::Black).bg(Color::Yellow), ); } let selection_style = Style::default().fg(Color::White).bg(Color::Blue); if editor.linewise_rows().any(|rows| rows.contains(&row)) { paint_cells( frame, area, left, left, display_column(line, usize::MAX).saturating_add(1), selection_style, ); } for selection in selections .iter() .filter(|_| !editor.visual_linewise()) .chain(editor.snippet_selections()) { if selection.start.row <= row && row <= selection.end.row { let start = if row == selection.start.row { display_column(line, selection.start.column) } else { 0 }; let end = if row == selection.end.row { display_column(line, selection.end.column) } else { display_column(line, usize::MAX).saturating_add(1) }; paint_cells(frame, area, left, start, end, selection_style); } } for caret in carets.iter().filter(|caret| { editor.render_pane_focused() && caret.row == row && **caret != editor.cursor() }) { let column = display_column(line, caret.column); paint_cells( frame, area, left, column, column.saturating_add(1), Style::default().fg(Color::Black).bg(Color::Magenta), ); } } fn paint_diff_editor( frame: &mut Frame, editor: &Editor, view: ColumnView<'_>, area: Rect, viewport: (usize, usize), wrapped: Option<&[WrappedDiffRow]>, ) -> Option { let (top, left) = viewport; let carets = editor.carets(); let selections = editor.selections(); let source_cursor = editor.cursor(); let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column); let mut cursor = None; let focus = view.info.focus; for (screen, display, segments) in diff_screen_rows(view.info.display_len, top, area.height, wrapped) { let Some(source) = view.native_row(display) else { continue; }; let mut line_area = Rect::new(area.x, area.y + screen as u16, area.width, 1); let segment = segments.and_then(|segments| segments[usize::from(focus == Focus::After)]); if segments.is_some() && segment.is_none() { continue; } let left = segment.map_or(left, |segment| segment.start); if let Some(segment) = segment { let final_segment = wrapped.is_some_and(|rows| { rows[screen].final_segments[usize::from(focus == Focus::After)] }); line_area.width = segment_paint_width(segment, final_segment, usize::from(area.width)) as u16; } paint_editor_line(frame, editor, source, line_area, left, &carets, &selections); if source == source_cursor.row && editor.render_pane_focused() && (editor.diff_interacting() || editor .resolver() .is_some_and(|resolver| resolver.view == ResolverView::History)) { let column = cursor_column; if column >= left && column < left.saturating_add(usize::from(line_area.width)) { cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y)); } } } cursor } fn draw_diff_popups( frame: &mut Frame, editor: &Editor, area: Rect, caret: Option<(Rect, Position)>, panels: &mut WheelPanels, ) { if !editor.render_pane_focused() { return; } panels.caret = caret; if !panels.workspace_managed { draw_overlays(frame, editor, area, caret, panels); } } fn draw_diff( frame: &mut Frame, diff: &DiffState, area: Rect, navigation_hint: &str, file_navigation_hint: &str, panels: &mut WheelPanels, editor: Option<&Editor>, ) { if let Some(editor) = editor { panels.picker_scroll.distance = editor.scroll_distance(); } if diff.phase == Phase::View { panels.picker_scroll.revisions = None; } let comparison = diff.comparison.as_ref(); let selected = comparison.and_then(|comparison| comparison.files.get(diff.file)); let syntax_error = panels.cached_diff_error(Some(diff)); if area.width == 0 || area.height == 0 { return; } if diff.phase != Phase::View { let pull_requests = diff.pull_request_remote.is_some(); let panel = floating_panel(area); frame.render_widget(Clear, panel); let stage = if diff.phase == Phase::Before { "Before" } else { "After" }; let title = if pull_requests { " Pull requests ".into() } else { format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label()) }; let content = diff_panel(frame, panel, &title, "", "", true); let pull_request_notice = (pull_requests && diff.phase == Phase::Before) .then(|| diff.pull_request_notice()) .flatten(); let notice = diff .error .as_deref() .or(pull_request_notice.as_deref()) .or_else(|| { if diff.loading { Some( if diff.after.is_some() || (pull_requests && diff.phase == Phase::After) { "Loading comparison…" } else if pull_requests { "Loading pull requests…" } else { "Loading repository…" }, ) } else { diff.catalog .as_ref() .and_then(|catalog| catalog.notice.as_deref()) } }); let [ stage_area, tabs_area, query_area, notice_area, pinned_area, results, help, ] = Layout::vertical([ Constraint::Length(u16::from(content.height >= 5)), Constraint::Length(u16::from(content.height >= 4)), Constraint::Length(1), Constraint::Length(u16::from(notice.is_some() && content.height >= 3)), Constraint::Length(u16::from(!pull_requests && content.height >= 2)), Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 7)), ]) .areas(content); let before = diff .before .as_ref() .map_or("not selected", |revision| revision.label.as_str()); let stage_text = if pull_requests { if diff.pull_request_partial() { "Partial search · Select a loaded pull request".into() } else { "Select a pull request to compare its branch changes".into() } } else { format!( "{} · Ctrl+b toggle · Before: {}", diff.comparison_mode.label(), display_text(before) ) }; frame.render_widget(Paragraph::new(stage_text), stage_area); if pull_requests || diff.catalog.is_some() { let tabs: Vec = diff .categories() .iter() .enumerate() .map(|(index, category)| { Span::styled( format!(" {} ", category.label()), if index == diff.category { Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }, ) }) .collect(); frame.render_widget(Paragraph::new(Line::from(tabs)), tabs_area); } let query = format!( "> {}{}", if pull_requests && diff.pull_request_partial() { "[partial] " } else { "" }, display_text(&diff.query) ); let width = usize::from(query_area.width); let query_width = display_column(&query, usize::MAX); let offset = query_width.saturating_sub(width.saturating_sub(1)); frame.render_widget( Paragraph::new(visible_line(&query, offset, width)) .style(Style::default().fg(Color::Yellow)), query_area, ); if query_area.width > 0 && query_area.height > 0 && editor.is_none_or(|editor| editor.render_pane_focused()) { frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); } if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( if diff.error.is_some() { Color::Red } else { Color::Yellow }, )), notice_area, ); } let mut entries = diff.entries(); if !pull_requests && let Some(working) = entries.next() { let label = format!( "{} {} · pinned", if diff.selected == 0 { ">" } else { " " }, display_text(&working.label) ); frame.render_widget( Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style( if diff.selected == 0 { Style::default() .bg(Color::Blue) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Cyan) }, ), pinned_area, ); } let row_height = if results.width >= 50 && results.height >= 4 { 2 } else { 1 }; let count = usize::from(results.height) / row_height; let pinned = usize::from(!pull_requests); let mut key = std::collections::hash_map::DefaultHasher::new(); ( diff.phase as u8, diff.category, &diff.query, diff.comparison_mode as u8, diff.catalog .as_ref() .map(|catalog| (catalog.entries.as_ptr(), catalog.entries.len())), ) .hash(&mut key); let first = PickerViewport::follow( &mut panels.picker_scroll.revisions, key.finish(), diff.selected.saturating_sub(pinned), usize::from(results.height), row_height, panels.picker_scroll.distance, ); let mut lines = Vec::with_capacity(count * row_height); for (index, revision) in entries .enumerate() .skip(first) .map(|(index, revision)| (index + pinned, revision)) .take(count) { let style = if index == diff.selected { Style::default() .bg(Color::Blue) .add_modifier(Modifier::BOLD) } else { Style::default() }; let label = format!( "{} {}", if index == diff.selected { ">" } else { " " }, display_text(&revision.label) ); let width = usize::from(results.width); if row_height == 2 { lines.push(Line::styled(visible_line(&label, 0, width), style)); lines.push(Line::styled( visible_line( &format!(" {}", display_text(&revision.description)), 0, width, ), style.fg(Color::DarkGray), )); } else { lines.push(Line::styled( visible_line( &format!("{label} · {}", display_text(&revision.description)), 0, width, ), style, )); } } if lines.is_empty() && !(if pull_requests { diff.pull_request_partial() } else { diff.loading }) && diff.error.is_none() { let empty = if pull_requests { "No matching pull requests" } else { "No matching revisions" }; frame.render_widget(Paragraph::new(empty), results); } else { frame.render_widget(Paragraph::new(lines), results); } let accept = if diff.error.is_some() { "retry" } else { "select" }; let retry = if pull_requests { " · Ctrl+r retry category" } else { "" }; frame.render_widget( Paragraph::new(format!( "{navigation_hint} · Tab categories · type search · Enter {accept}{retry} · Esc close · Ctrl+p actions" )) .style(Style::default().fg(Color::DarkGray)), help, ); panels.diff = Some(DiffGeometry { regions: [ (panel, Focus::Files), (Rect::default(), Focus::Before), (Rect::default(), Focus::After), ], viewport: None, text_widths: [0; 2], wrapping: diff.word_wrap, view_mode: diff.view_mode, split_axis: diff.split_axis, caret_row: None, }); if let Some(editor) = editor { draw_diff_popups(frame, editor, area, None, panels); } return; } frame.render_widget(Clear, area); let resolver = editor .and_then(Editor::resolver) .filter(|resolver| resolver.view == ResolverView::Resolution); let resolver_error = resolver.and_then(|resolver| { resolver.error.as_deref().or_else(|| { resolver .files .get(resolver.current_file) .and_then(|file| file.error.as_deref()) }) }); let notice = diff.error.as_deref().or(resolver_error).or_else(|| { if diff.loading || resolver.is_some_and(|resolver| resolver.loading) { Some("Loading file…") } else { syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref())) } }); let [notice_area, workspace] = Layout::vertical([ Constraint::Length(u16::from(notice.is_some() && area.height >= 3)), Constraint::Fill(1), ]) .areas(area); if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( if diff.error.is_some() || resolver_error.is_some() || syntax_error.is_some() { Color::Red } else { Color::Yellow }, )), notice_area, ); } let (panes, navigator) = if !panels.workspace_managed && diff.files_visible && diff .files .as_ref() .is_some_and(|files| files.presentation == FilePresentation::Docked) { dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files) } else { (workspace, None) }; if let Some(picker) = &diff.files && let Some(navigator) = navigator { draw_file_list( frame, picker, navigator, file_navigation_hint, panels, comparison.map(|comparison| (comparison.files.as_slice(), &diff.reviewed_files)), ); } let document_caret = panels.draw_documents(frame, diff, panes, navigator, editor); if let Some(editor) = editor { draw_diff_popups(frame, editor, area, document_caret, panels); } } impl WheelPanels { /// Read the previous draw's error without changing notice-layout timing. fn cached_diff_error(&mut self, diff: Option<&DiffState>) -> Option<&str> { if diff.is_none_or(|diff| diff.phase != Phase::View || !diff.selected_file_loaded()) { self.diff_syntax = None; } self.diff_syntax .as_ref() .and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error())) } /// Publish geometry and return the caret only after all document overlays. fn draw_documents( &mut self, frame: &mut Frame, diff: &DiffState, panes: Rect, navigator: Option, editor: Option<&Editor>, ) -> Option<(Rect, Position)> { self.cached_diff_error(Some(diff)); let selected = diff .comparison .as_ref() .and_then(|comparison| comparison.files.get(diff.file)); let resolver = editor .and_then(Editor::resolver) .filter(|resolver| resolver.view == ResolverView::Resolution); if let Some(resolver) = resolver.filter(|resolver| { resolver .files .get(resolver.current_file) .is_some_and(|file| !file.text) }) { draw_resolver_choices(frame, resolver, panes); self.diff = Some(DiffGeometry { regions: [ (navigator.unwrap_or_default(), Focus::Files), (Rect::default(), Focus::Before), (Rect::default(), Focus::After), ], viewport: None, text_widths: [0; 2], wrapping: diff.word_wrap, view_mode: diff.view_mode, split_axis: diff.split_axis, caret_row: None, }); return None; } let pane_layout = diff.pane_layout(); let unified = diff.view_mode == ViewMode::Unified; let [before_area, after_area] = if unified { [panes, panes] } else { match pane_layout { PaneLayout::Split => { split_rect(pane_rect(panes), diff.split_axis).map(terminal_rect) } PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()], PaneLayout::After => [Rect::default(), panes], } }; // Parse complete trees before projecting context; color only visible source rows below. if let Some(id) = diff.comparison_id && let Some(file) = selected.filter(|file| file.loaded) { self.diff_syntax .get_or_insert_with(DiffSyntaxCache::default) .update( id, diff.file, file, diff.after_source_lines(file), diff.live_revision, Some(DiffSyntaxViewport { diff, top: diff.top, areas: [Rect::default(); 2], wrapped: None, }), ); } let mut document_caret = None; let mut top = diff.top; let mut before_left = diff.left_before; let mut after_left = diff.left_after; let wrapping = diff.word_wrap; let toggle_row = self .previous_diff .as_ref() .filter(|previous| { (previous.wrapping != wrapping || previous.view_mode != diff.view_mode || previous.split_axis != diff.split_axis) && previous.viewport.is_some_and(|(id, file, _, _, _)| { Some(id) == diff.comparison_id && file == diff.file }) }) .and_then(|previous| { editor?.diff_buffer_focus()?; previous.caret_row }); let center_row = editor.and_then(|editor| { let focus = editor.diff_buffer_focus()?; let pane = if focus == Focus::Before { before_area } else { after_area }; let height = usize::from(diff_inner(pane).height); let file = selected.filter(|file| file.loaded)?; (editor.diff_interacting() && diff.follow_caret && height > 0 && diff_pane_text_width(diff, file, pane, focus) > 0 && diff .source_display_row(focus, editor.cursor().row) .is_some() && editor.take_lsp_center()) .then_some(height / 2) }); let toggle_row = center_row.or(toggle_row); if let Some(editor) = editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret) && let Some(focus) = editor.diff_buffer_focus() { let (pane, left) = match focus { Focus::Before => (before_area, &mut before_left), Focus::After => (after_area, &mut after_left), Focus::Files => unreachable!("native diff buffers belong to a text pane"), }; let source_len = selected.map_or(0, |file| match focus { Focus::Before => file.before.len(), Focus::After => file.after.len(), Focus::Files => unreachable!("native buffer belongs to a text pane"), }); let width = selected.map_or_else( || diff_text_width(pane, source_len), |file| diff_pane_text_width(diff, file, pane, focus), ); let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column); *left = scroll_offset(*left, column, width, editor.scroll_distance()); } if let Some(file) = selected.filter(|file| file.loaded) && let Some(syntax) = &self.diff_syntax { let caret = editor .filter(|editor| editor.diff_interacting() && diff.follow_caret) .and_then(|editor| { editor .diff_buffer_focus() .map(|focus| (focus, editor.cursor())) }); let viewport = self.diff_wrap.get_or_insert_with(DiffWrapViewport::default); viewport.lefts = [before_left, after_left]; viewport.update( diff, file, [before_area, after_area], caret, toggle_row, editor.map_or(Some(0), Editor::scroll_distance), syntax, ); let height = editor .and_then(|editor| editor.diff_buffer_focus()) .map_or(0, |focus| { usize::from( diff_inner(if focus == Focus::Before { before_area } else { after_area }) .height, ) }); if height > 0 { let scroll = editor.map_or(0, |editor| editor.take_viewport_scroll(height)); for _ in 0..scroll.unsigned_abs() { let next = viewport.step(diff, file, viewport.top, scroll > 0); if next == viewport.top { break; } viewport.top = next; } if scroll != 0 { viewport.fill( diff, file, viewport.heights[0].max(viewport.heights[1]), syntax, ); } } top = viewport.top.display; } else { self.diff_wrap = None; } if let Some(id) = diff.comparison_id && let Some(file) = selected.filter(|file| file.loaded) { let wrapped = self .diff_wrap .as_ref() .map(|viewport| viewport.rows.as_slice()); self.diff_syntax .get_or_insert_with(DiffSyntaxCache::default) .update( id, diff.file, file, diff.after_source_lines(file), diff.live_revision, Some(DiffSyntaxViewport { diff, top, areas: [before_area, after_area], wrapped, }), ); } let mut text_widths = [0; 2]; let previous_text_widths = self .previous_diff .as_ref() .filter(|geometry| { geometry.viewport.is_some_and(|(id, file, _, _, _)| { Some(id) == diff.comparison_id && file == diff.file }) }) .map(|geometry| geometry.text_widths); let wrapped = self .diff_wrap .as_ref() .map(|viewport| viewport.rows.as_slice()); if let Some(file) = selected.filter(|file| file.loaded) && let Some(syntax) = &self.diff_syntax { if pane_layout == PaneLayout::Empty { diff_pane_panel(frame, diff, Some(file), before_area, true, resolver); } else { if before_area != Rect::default() && (!unified || diff.focus == Focus::Before) { let text_area = draw_diff_pane( frame, diff, file, before_area, true, [&syntax.before, &syntax.after], [ editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)), editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)), ], ( top, &mut before_left, previous_text_widths.map(|widths| widths[0]), ), wrapped, self.diff_colors, editor.is_some_and(Editor::highlight_trailing_whitespace), editor.map(Editor::whitespace_characters), editor.map_or(WhitespaceScope::All, Editor::whitespace_scope), resolver, false, ); text_widths[0] = text_area.width; if let Some(editor) = editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before)) { document_caret = paint_diff_editor( frame, editor, diff.column_view(Focus::Before).unwrap(), text_area, (top, before_left), wrapped, ) .map(|position| (text_area, position)); } } if after_area != Rect::default() && (!unified || diff.focus != Focus::Before) { let text_area = draw_diff_pane( frame, diff, file, after_area, false, [&syntax.before, &syntax.after], [ editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)), editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)), ], ( top, &mut after_left, previous_text_widths.map(|widths| widths[1]), ), wrapped, self.diff_colors, editor.is_some_and(Editor::highlight_trailing_whitespace), editor.map(Editor::whitespace_characters), editor.map_or(WhitespaceScope::All, Editor::whitespace_scope), resolver, false, ); text_widths[1] = text_area.width; if let Some(editor) = editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After)) { document_caret = paint_diff_editor( frame, editor, diff.column_view(Focus::After).unwrap(), text_area, (top, after_left), wrapped, ) .map(|position| (text_area, position)); } } } } else { let content = diff_pane_panel(frame, diff, selected, panes, true, resolver); frame.render_widget( Paragraph::new(display_text(if diff.loading { "Loading file…" } else if let Some(error) = diff.error.as_deref() { error } else if selected.is_some() { "File details unavailable · select the file to retry" } else { "No changed files" })) .wrap(Wrap { trim: false }) .style(Style::default().fg(if diff.error.is_some() { Color::Red } else { Color::Reset })), content, ); } if let (Some(file), Some(syntax), Some(viewport)) = ( selected.filter(|file| file.loaded), self.diff_syntax.as_ref(), self.diff_wrap.as_ref(), ) { draw_diff_scope_labels( frame, diff, file, syntax, viewport, [before_area, after_area], [before_left, after_left], &mut document_caret, ); } self.diff = Some(DiffGeometry { regions: [ (navigator.unwrap_or_default(), Focus::Files), ( if unified && diff.focus != Focus::Before { Rect::default() } else { before_area }, Focus::Before, ), ( if unified && diff.focus == Focus::Before { Rect::default() } else { after_area }, Focus::After, ), ], viewport: diff .comparison_id .map(|id| (id, diff.file, top, before_left, after_left)), text_widths, wrapping, view_mode: diff.view_mode, split_axis: diff.split_axis, caret_row: document_caret .map(|(text_area, caret)| usize::from(caret.y.saturating_sub(text_area.y))), }); document_caret } } fn diff_status(status: FileStatus) -> &'static str { match status { FileStatus::Added => "A", FileStatus::Removed => "D", FileStatus::Modified => "M", FileStatus::Renamed => "R", } } fn diff_pane_panel( frame: &mut Frame, diff: &DiffState, file: Option<&ChangedFile>, area: Rect, before: bool, resolver: Option<&ResolverWorkspace>, ) -> Rect { if area.width < 4 || area.height < 4 { return area; } let focus = if before { Focus::Before } else { Focus::After }; if let Some(resolver) = resolver { let file = resolver.files.get(resolver.current_file); let title = if before { " Resolution " } else { " Other version " }; let state = if before { format!( " editable · {} ", file.map_or("loading", |file| resolver_status(&file.status).0) ) } else { " read-only ".to_owned() }; let source = if before { String::new() } else { file.and_then(|file| file.sources.get(resolver.selected_source)) .map_or_else(String::new, |source| { format!(" {} ", display_text(&source.label)) }) }; return diff_panel(frame, area, title, &state, &source, diff.focus == focus); } let label = diff.side_label(focus); let title = file.map_or_else( || format!(" Diff · {} ", diff.comparison_mode.label()), |file| { let label = if diff.view_mode == ViewMode::Unified { Cow::Owned(format!("Unified · {label}")) } else { Cow::Borrowed(label) }; let path = if before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; format!( " {label} · {} · {} · {} ", diff_status(file.status), display_text(&path.to_string_lossy()), diff.comparison_mode.label() ) }, ); let revision = diff .comparison .as_ref() .map_or_else(String::new, |comparison| { if diff.view_mode == ViewMode::Split && diff.pane_layout() == PaneLayout::Split { format!( " {} ", display_text(if before { &comparison.before } else { &comparison.after }) ) } else { format!( " {} → {} ", display_text(&comparison.before), display_text(&comparison.after) ) } }); let access = if diff.side_editable(focus) { " editable " } else { " read-only " }; diff_panel(frame, area, &title, access, &revision, diff.focus == focus) } #[allow(clippy::too_many_arguments)] fn draw_diff_scope_labels( frame: &mut Frame, diff: &DiffState, file: &ChangedFile, syntax: &DiffSyntaxCache, viewport: &DiffWrapViewport, areas: [Rect; 2], lefts: [usize; 2], caret: &mut Option<(Rect, Position)>, ) { let unified = diff.view_mode == ViewMode::Unified; for (pane, focus) in [Focus::Before, Focus::After].into_iter().enumerate() { if areas[pane].is_empty() || (unified && (diff.focus == Focus::Before) != (pane == 0)) { continue; } let content = diff_inner(areas[pane]); let gutter = diff_gutter(diff, file, usize::from(content.width), focus); let width = usize::from(content.width).saturating_sub(gutter); for (screen, row) in viewport .rows .iter() .take(usize::from(content.height)) .enumerate() { let overlay = row.scope == [false; 2]; if overlay && !row.unwrapped { continue; } let contexts = syntax.scopes(diff, row.position.display); let side = if !unified { pane } else if overlay { usize::from(diff.display_focus(row.position.display) != Some(Focus::Before)) } else { usize::from(!row.scope[0]) }; let Some(scope) = contexts[side] else { continue; }; let y = content.y + screen as u16; let y = if overlay { let source_focus = if side == 0 { Focus::Before } else { Focus::After }; if diff.display_source_row(source_focus, row.position.display) != Some(scope.name.row) { continue; } let source = if side == 0 { &file.before } else { &file.after }; let prefix = &source[scope.name.row][..scope.name.column]; let column = display_column(prefix, usize::MAX); if lefts[pane] <= column && column < lefts[pane].saturating_add(width) { continue; } let Some(y) = y.checked_sub(1).filter(|&y| y >= content.y) else { continue; }; let previous = &viewport.rows[screen - 1]; let previous_focus = if unified { diff.display_focus(previous.position.display) .unwrap_or(focus) } else { focus }; let previous_side = usize::from(previous_focus == Focus::After); if previous.scope == [false; 2] && let Some(previous_scope) = syntax.scopes(diff, previous.position.display)[previous_side] && diff.display_source_row(previous_focus, previous.position.display) == Some(previous_scope.name.row) { let source = if previous_side == 0 { &file.before } else { &file.after }; let prefix = &source[previous_scope.name.row][..previous_scope.name.column]; let column = display_column(prefix, usize::MAX); if lefts[pane] <= column && column < lefts[pane].saturating_add(width) { continue; } } y } else { if !row.scope[side] { continue; } y }; let shared = if overlay { contexts[0].map(|scope| scope.label) == contexts[1].map(|scope| scope.label) } else { row.scope == [true; 2] }; let prefix = if width < 4 { "" } else if !unified { "› " } else if shared { "± " } else if side == 0 { "− " } else { "+ " }; let prefix_width = UnicodeWidthStr::width(prefix); let available = width.saturating_sub(prefix_width); let label = if UnicodeWidthStr::width(scope.label) > available { scope.label.rsplit(" › ").next().unwrap_or(scope.label) } else { scope.label }; let style = Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC); let x = content.x + gutter as u16; let buffer = frame.buffer_mut(); for x in content.x..content.right() { buffer[(x, y)].reset(); } buffer.set_stringn(x, y, prefix, width, style); buffer.set_stringn(x + prefix_width as u16, y, label, available, style); if caret.is_some_and(|(_, position)| { position.y == y && content.x <= position.x && position.x < content.right() }) { *caret = None; } } } } #[allow(clippy::too_many_arguments)] fn draw_diff_pane( frame: &mut Frame, diff: &DiffState, file: &ChangedFile, area: Rect, before: bool, syntax: [&SyntaxCache; 2], semantic: [&[SemanticToken]; 2], viewport: (usize, &mut usize, Option), wrapped: Option<&[WrappedDiffRow]>, colors: DiffColors, highlight_trailing_whitespace: bool, whitespace_characters: Option<&BTreeMap>, whitespace_scope: WhitespaceScope, resolver: Option<&ResolverWorkspace>, content_only: bool, ) -> Rect { if area.width == 0 || area.height == 0 { return area; } let focus = if before { Focus::Before } else { Focus::After }; let content = if content_only { area } else { diff_pane_panel(frame, diff, Some(file), area, before, resolver) }; let view = diff.column_view(focus).expect("prepared comparison column"); let unified = diff.view_mode == ViewMode::Unified; let (top, left, previous_width) = viewport; let gutter = diff_gutter(diff, file, usize::from(content.width), focus); let width = usize::from(content.width).saturating_sub(gutter); if view.info.display_len > 0 && !diff.word_wrap && !content_only && previous_width.is_some_and(|previous| usize::from(previous) != width) { *left = diff.bound_horizontal(focus, *left, width); } let left = *left; let review_comments = diff .local_review .document .comments .iter() .filter(|comment| { let before = comment.anchor.side == crate::review::Side::Before; let path = if before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; (unified || before == (focus == Focus::Before)) && comment.anchor.path == *path && diff.review_anchor_current(&comment.anchor) }); paint_diff_content( frame, view, content, syntax, semantic, (top, left), wrapped, colors, highlight_trailing_whitespace, whitespace_characters, whitespace_scope, review_comments, diff.local_review.selected, ) } #[allow(clippy::too_many_arguments)] fn paint_diff_content<'a>( frame: &mut Frame, view: ColumnView<'a>, content: Rect, syntax: [&SyntaxCache; 2], semantic: [&[SemanticToken]; 2], viewport: (usize, usize), wrapped: Option<&[WrappedDiffRow]>, colors: DiffColors, highlight_trailing_whitespace: bool, whitespace_characters: Option<&BTreeMap>, whitespace_scope: WhitespaceScope, review_comments: impl Iterator + Clone, selected_review: Option, ) -> Rect { if view.info.display_len == 0 { let notice = match view.info.empty { ColumnEmpty::Notice(text) => display_text(text), ColumnEmpty::EmptyFile => "Empty file".into(), ColumnEmpty::NoChanges => "No text changes".into(), }; frame.render_widget(Paragraph::new(notice), content); return content; } let unified = view.info.unified; let (top, left) = viewport; let gutter = column_gutter( unified, view.info.source_extents, usize::from(content.width), view.info.focus, ); let width = usize::from(content.width).saturating_sub(gutter); let selected_anchor = review_comments .clone() .find(|comment| selected_review == Some(comment.id)) .map(|comment| &comment.anchor); for (screen, display_row, segments) in diff_screen_rows(view.info.display_len, top, content.height, wrapped) { let row_area = Rect::new(content.x, content.y + screen as u16, content.width, 1); let line = match view.row(display_row) { Some(ColumnRow::Fold { hidden }) => { frame.render_widget( Paragraph::new(format!( "… {} unchanged lines hidden · [/] expand context", hidden )) .style(Style::default().fg(Color::DarkGray)), row_area, ); continue; } Some(ColumnRow::Text(line)) => line, Some(ColumnRow::Gap) => { let style = Style::default().fg(Color::DarkGray).bg(Color::Reset); let buffer = frame.buffer_mut(); for x in 0..content.width { buffer[(content.x + x, row_area.y)] .set_symbol("╱") .set_style(style); } continue; } None => continue, }; let Some(source_text) = line.text else { continue; }; let before = line.side == Focus::Before; let index = line.source_row; let segment = segments.and_then(|segments| segments[usize::from(!before)]); let left = segment.map_or(left, |segment| segment.start); let text_width = segment.map_or(width, |segment| segment.end - segment.start); let paint_width = segment.map_or(width, |segment| { let final_segment = wrapped.is_some_and(|rows| rows[screen].final_segments[usize::from(!before)]); segment_paint_width(segment, final_segment, width) }); let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1); let changed = line.changed; let background = if changed && line.emphasize { colors.row[usize::from(!before)] } else { Color::Reset }; frame .buffer_mut() .set_style(row_area, Style::default().bg(background)); if segments.is_some() && segment.is_none() { continue; } frame.render_widget( Paragraph::new(visible_text(source_text, left, text_width.min(width), true)) .style(Style::default().fg(Color::Reset).bg(background)), Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), ); if gutter > 0 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 0) { let marker = if changed { if before { '-' } else { '+' } } else { ' ' }; let color = if changed { if before { Color::Red } else { Color::Green } } else { Color::DarkGray }; let label = if unified { let [old, new] = line.numbers.map(|row| row.map(|row| row + 1)); let [old_digits, new_digits] = view .info .stored_lengths .map(|len| len.max(1).ilog10() as usize + 1); match (old, new) { (Some(old), Some(new)) => { format!("{old:>old_digits$} {new:>new_digits$}{marker} ") } (Some(old), None) => format!("{old:>old_digits$} {:>new_digits$}{marker} ", ""), (None, Some(new)) => format!("{:>old_digits$} {new:>new_digits$}{marker} ", ""), (None, None) => unreachable!("displayed diff line has a source"), } } else { let digits = view.info.source_extents[usize::from(!before)] .max(1) .ilog10() as usize + 1; format!("{:>digits$}{marker} ", index + 1) }; frame.render_widget( Paragraph::new(label).style(Style::default().fg(color).bg(background)), Rect::new(content.x, row_area.y, gutter as u16, 1), ); } paint_syntax_line( frame, source_text, syntax[usize::from(!before)].line(index), text_area, left, ); paint_semantic_line( frame, source_text, semantic[usize::from(!before)], index, text_area, left, ); if let Some(characters) = whitespace_characters { paint_whitespace( frame, source_text, text_area, left, characters, whitespace_scope, syntax[usize::from(!before)].string_spans(index), line.newline, true, ); } if line.emphasize { let style = Style::default().bg(colors.character[usize::from(!before)]); for range in line.changes() { if !range.is_empty() { paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style); } } } if highlight_trailing_whitespace { let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') { Cow::Owned( source_text .chars() .map(|ch| { if ch.is_control() && ch != '\t' { '�' } else { ch } }) .collect::(), ) } else { Cow::Borrowed(source_text) }; paint_trailing_whitespace(frame, &text, text_area, left); } if let Some(anchor) = selected_anchor { let side = match anchor.side { crate::review::Side::Before => Focus::Before, crate::review::Side::After => Focus::After, }; if let Some(source_row) = view .review_row(side, display_row) .filter(|source_row| anchor.rows().contains(source_row)) { let start = if source_row == anchor.start.row { anchor.start.column } else { 0 }; let end = if source_row == anchor.end.row { anchor.end.column } else { usize::MAX }; paint_grapheme_range( frame, text_area, left, source_text, start..end, Style::default().bg(Color::Magenta), ); } } } if gutter > 0 { for comment in review_comments { let side = match comment.anchor.side { crate::review::Side::Before => Focus::Before, crate::review::Side::After => Focus::After, }; let selected = selected_review == Some(comment.id); for (screen, display, _) in diff_screen_rows(view.info.display_len, top, content.height, wrapped) { let source = view.review_row(side, display); if wrapped.is_some_and(|rows| rows[screen].position.continuation != 0) || !source.is_some_and(|row| comment.anchor.rows().contains(&row)) { continue; } let cell = &mut frame.buffer_mut() [(content.x + gutter as u16 - 1, content.y + screen as u16)]; if selected || cell.symbol() != "●" { cell.set_symbol(if selected { "●" } else { "○" }) .set_fg(Color::Magenta); if selected { cell.modifier.insert(Modifier::BOLD); } } } } } Rect::new( content.x + gutter as u16, content.y, width as u16, content.height, ) } fn floating_panel(area: Rect) -> Rect { let [_, column, _] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(area.width.min(100)), Constraint::Fill(1), ]) .areas(area); let [_, panel, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(area.height.min(24)), Constraint::Fill(1), ]) .areas(column); panel } fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) { draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None); } fn draw_diagnostic_panel( frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect, title: &str, output: Option<&crate::editor::ShellOutput>, ) { let startup = output.is_none(); let panel = floating_panel(area); frame.render_widget(Clear, panel); let bordered = panel.width >= 4 && panel.height >= 4; let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(if startup { Color::Red } else { Color::DarkGray })) .title(format!(" {} ", display_text(title))); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let [text_area, help_area] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 3)), ]) .areas(content); let lines: Vec = if let Some(output) = output.filter(|output| output.wrapping) { let width = usize::from(text_area.width).max(1); let mut cached = output.wrapped_lines.borrow_mut(); if cached.0 != width { let mut cache = crate::wrap::WrapCache::default(); cache.prepare((0, 0), width); cached.1.clear(); for (row, line) in diagnostic.lines.iter().enumerate() { let segments = cache.segments(row, line); for (index, segment) in segments.iter().enumerate() { // Output has no insertion caret: omit its extra empty continuation. if index == 0 || segment.start != segment.end { cached.1.push((row, *segment)); } } } cached.0 = width; } output.wrapped_rows.set(cached.1.len()); cached .1 .iter() .skip(diagnostic.top.min(cached.1.len().saturating_sub(1))) .take(usize::from(text_area.height)) .map(|(row, segment)| { Line::from(visible_line( &diagnostic.lines[*row], segment.start, (segment.end - segment.start).min(width), )) }) .collect() } else { diagnostic .lines .iter() .skip(diagnostic.top) .take(usize::from(text_area.height)) .map(|line| { let line = display_text(line); Line::from(visible_line( &line, display_column(&line, diagnostic.left), usize::from(text_area.width), )) }) .collect() }; frame.render_widget(Paragraph::new(lines), text_area); frame.render_widget( Paragraph::new(if startup { "Arrows scroll · Home reset · Enter/Esc continue" } else { "Arrows scroll · Home reset · Enter/Esc close" }) .style(Style::default().fg(Color::DarkGray)), help_area, ); if startup && text_area.width > 0 && text_area.height > 0 { frame.set_cursor_position((text_area.x, text_area.y)); } } fn segment_paint_width(segment: crate::wrap::Segment, final_segment: bool, width: usize) -> usize { let cells = segment.end - segment.start + usize::from(final_segment); width.min(cells.max(1)) } #[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] struct DiffWrapPosition { display: usize, continuation: usize, } #[derive(Clone, Copy)] struct WrappedDiffRow { position: DiffWrapPosition, segments: [Option; 2], final_segments: [bool; 2], scope: [bool; 2], unwrapped: bool, } #[derive(Default)] struct DiffWrapViewport { key: Option<(u64, usize, Option)>, view_mode: Option, revision: u64, before: crate::wrap::WrapCache, after: crate::wrap::WrapCache, widths: [usize; 2], lefts: [usize; 2], heights: [usize; 2], visible_names: [Vec; 2], top: DiffWrapPosition, rows: Vec, } fn diff_inner(area: Rect) -> Rect { if area.width >= 4 && area.height >= 4 { Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2) } else { area } } fn diff_text_width(area: Rect, source_len: usize) -> usize { let width = usize::from(diff_inner(area).width); let digits = source_len.max(1).ilog10() as usize + 1; width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 }) } fn diff_source_len(diff: &DiffState, file: &ChangedFile, before: bool) -> usize { let stored = if before { file.before.len() } else { file.after.len() }; let last = diff .rows .last() .and_then(|row| if before { row.before } else { row.after }); stored.max(last.map_or(0, |row| row + 1)) } fn diff_gutter(diff: &DiffState, file: &ChangedFile, width: usize, focus: Focus) -> usize { column_gutter( diff.view_mode == ViewMode::Unified, [ diff_source_len(diff, file, true), diff_source_len(diff, file, false), ], width, focus, ) } fn column_gutter(unified: bool, extents: [usize; 2], width: usize, focus: Focus) -> usize { let [old, new] = extents.map(|len| len.max(1).ilog10() as usize + 1); let gutter = if unified { old + new + 3 } else { (if focus == Focus::Before { old } else { new }) + 2 }; if width > gutter { gutter } else { 0 } } fn diff_pane_text_width(diff: &DiffState, file: &ChangedFile, area: Rect, focus: Focus) -> usize { let width = usize::from(diff_inner(area).width); width.saturating_sub(diff_gutter(diff, file, width, focus)) } impl DiffWrapViewport { fn row( &mut self, diff: &DiffState, file: &ChangedFile, position: DiffWrapPosition, ) -> WrappedDiffRow { let mut segments = [None; 2]; let mut final_segments = [false; 2]; if !diff.word_wrap { return WrappedDiffRow { position, segments, final_segments, scope: [false; 2], unwrapped: true, }; } if matches!( diff.display_rows.get(position.display), Some(DisplayRow::Line(_)) ) { for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() { let source = diff.display_source_row(focus, position.display); if let Some(source) = source.filter(|_| self.widths[side] > 0) { let (cache, lines) = if side == 0 { (&mut self.before, &file.before) } else { (&mut self.after, &file.after) }; let text = lines.get(source).map_or("", String::as_str); let parts = cache.segments(source, text); segments[side] = parts.get(position.continuation).copied(); final_segments[side] = position.continuation + 1 == parts.len(); } } } WrappedDiffRow { position, segments, final_segments, scope: [false; 2], unwrapped: false, } } fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize { if !diff.word_wrap { return 1; } if !matches!(diff.display_rows.get(display), Some(DisplayRow::Line(_))) { return 1; } let mut height = 1; for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() { let source = diff.display_source_row(focus, display); if let Some(source) = source.filter(|_| self.widths[side] > 0) { let (cache, lines) = if side == 0 { (&mut self.before, &file.before) } else { (&mut self.after, &file.after) }; height = height.max( cache .segments(source, lines.get(source).map_or("", String::as_str)) .len(), ); } } height } fn step( &mut self, diff: &DiffState, file: &ChangedFile, mut position: DiffWrapPosition, down: bool, ) -> DiffWrapPosition { if down { if position.continuation + 1 < self.height(diff, file, position.display) { position.continuation += 1; } else if position.display + 1 < diff.display_rows.len() { position.display += 1; position.continuation = 0; } } else if position.continuation > 0 { position.continuation -= 1; } else if position.display > 0 { position.display -= 1; position.continuation = self.height(diff, file, position.display) - 1; } position } fn declaration_visible( &self, diff: &DiffState, file: &ChangedFile, side: usize, name: Point, ) -> bool { let focus = if side == 0 { Focus::Before } else { Focus::After }; let source = if side == 0 { &file.before } else { &file.after }; let column = display_column(source.get(name.row).map_or("", String::as_str), name.column); self.rows.iter().take(self.heights[side]).any(|row| { if row.scope != [false; 2] || diff.display_source_row(focus, row.position.display) != Some(name.row) { return false; } if row.unwrapped { return true; } let Some(segment) = row.segments[side] else { return false; }; let (left, right) = (segment.start, segment.end); left <= column && column < right }) } fn fill( &mut self, diff: &DiffState, file: &ChangedFile, height: usize, syntax: &DiffSyntaxCache, ) { for names in &mut self.visible_names { names.clear(); } // ponytail: reflow only this viewport; index visual rows if tall terminals make it hot. loop { self.rows.clear(); if diff.display_rows.is_empty() { return; } let mut position = self.top; let mut sticky = [true; 2]; while self.rows.len() < height { let contexts = syntax.scopes(diff, position.display); for mut scope in syntax.labels(diff, position, contexts, sticky) { for side in 0..2 { scope[side] &= !contexts[side].is_some_and(|context| { self.visible_names[side].contains(&context.name) }); } if scope.iter().any(|side| *side) && self.rows.len() + 1 < height { if self.rows.is_empty() { // The sticky slot can still show the adjacent declaration as source. let previous = self.step(diff, file, position, false); let declaration = self.row(diff, file, previous); let visible = |side: usize| { let focus = if side == 0 { Focus::Before } else { Focus::After }; let Some(context) = contexts[side] else { return false; }; if diff.display_source_row(focus, previous.display) != Some(context.name.row) { return false; } let source = if side == 0 { &file.before } else { &file.after }; let prefix = &source[context.name.row][..context.name.column]; let column = display_column(prefix, usize::MAX); if declaration.unwrapped { let pane = if diff.view_mode == ViewMode::Unified { usize::from(diff.focus != Focus::Before) } else { side }; self.lefts[pane] <= column && column < self.lefts[pane].saturating_add(self.widths[pane]) } else { declaration.segments[side].is_some_and(|segment| { segment.start <= column && column < segment.end }) } }; let restore = if diff.view_mode == ViewMode::Unified { let side = usize::from( diff.display_focus(previous.display) != Some(Focus::Before), ); scope[side] && visible(side) } else { (0..2).all(|side| !scope[side] || visible(side)) }; if previous != position && restore { self.rows.push(declaration); continue; } } self.rows.push(WrappedDiffRow { position, segments: [None; 2], final_segments: [false; 2], scope, unwrapped: false, }); } } for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() { if diff.display_source_row(focus, position.display).is_some() { sticky[side] = false; } } let row = self.row(diff, file, position); self.rows.push(row); let next = self.step(diff, file, position, true); if next == position { break; } position = next; } let mut changed = false; for index in 0..self.rows.len() { let row = self.rows[index]; if row.scope == [false; 2] { continue; } let contexts = syntax.scopes(diff, row.position.display); for side in 0..2 { if let Some(context) = contexts[side] && row.scope[side] && !self.visible_names[side].contains(&context.name) && self.declaration_visible(diff, file, side, context.name) { self.visible_names[side].push(context.name); changed = true; } } } if !changed { break; } } } fn caret_position( &mut self, diff: &DiffState, file: &ChangedFile, focus: Focus, cursor: Cursor, ) -> Option { let display = diff.source_display_row(focus, cursor.row)?; let (cache, source) = if focus == Focus::Before { (&mut self.before, &file.before) } else { (&mut self.after, &file.after) }; let text = source.get(cursor.row).map_or("", String::as_str); let column = display_column(text, cursor.column); let continuation = if diff.word_wrap { caret_segment(cache.segments(cursor.row, text), column) } else { 0 }; Some(DiffWrapPosition { display, continuation, }) } fn screen_row(&self, position: DiffWrapPosition) -> Option { self.rows .iter() .position(|row| row.scope == [false; 2] && row.position == position) } fn update( &mut self, diff: &DiffState, file: &ChangedFile, areas: [Rect; 2], caret: Option<(Focus, Cursor)>, anchor_row: Option, distance: Option, syntax: &DiffSyntaxCache, ) { let Some(id) = diff.comparison_id else { return }; let key = Some((id, diff.file, diff.live_revision)); if self.key.map(|(id, file, _)| (id, file)) != Some((id, diff.file)) || self.top.display != diff.top || self.view_mode != Some(diff.view_mode) { self.top = DiffWrapPosition { display: diff.top, continuation: 0, }; } self.view_mode = Some(diff.view_mode); if self.key != key { self.key = key; self.revision = self .revision .checked_add(1) .expect("wrap revisions exhausted"); } self.widths = [ diff_pane_text_width(diff, file, areas[0], Focus::Before), diff_pane_text_width(diff, file, areas[1], Focus::After), ]; self.heights = areas.map(|area| usize::from(diff_inner(area).height)); self.before.prepare((diff.file, id), self.widths[0]); self.after .prepare((diff.file, self.revision), self.widths[1]); self.top.display = self .top .display .min(diff.display_rows.len().saturating_sub(1)); self.top.continuation = self .top .continuation .min(self.height(diff, file, self.top.display) - 1); let active = usize::from(diff.focus == Focus::After || areas[0].height == 0); let height = usize::from(diff_inner(areas[active]).height); let paint_height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height)); self.fill(diff, file, paint_height, syntax); if let Some((focus, cursor)) = caret && let Some(position) = self.caret_position(diff, file, focus, cursor) { let margin = scroll_margin(height, distance); let row = self.screen_row(position).filter(|&row| row < height); let anchor = anchor_row.or_else(|| { if height == 0 { None } else if margin == height / 2 || position < self.top || row.is_some_and(|row| row < margin) { Some(margin) } else if row.is_none_or(|row| row >= height - margin) { Some(height - margin - 1) } else { None } }); if let Some(row) = anchor { self.top = position; let target = row.min(height.saturating_sub(1)); // Skip only positions guaranteed to precede the target, even if every // potential label is shown. Each side can also add one sticky label. let mut upper_bound = 2; let mut candidate = position; loop { let contexts = syntax.scopes(diff, candidate.display); upper_bound += syntax .labels(diff, candidate, contexts, [false; 2]) .into_iter() .filter(|scope| scope[0] || scope[1]) .count(); if upper_bound >= target { break; } self.top = candidate; let previous = self.step(diff, file, candidate, false); if previous == candidate { break; } candidate = previous; upper_bound += 1; } self.fill(diff, file, paint_height, syntax); while self .screen_row(position) .is_some_and(|screen| screen < target) { let previous = self.step(diff, file, self.top, false); if previous == self.top { break; } let top = self.top; self.top = previous; self.fill(diff, file, paint_height, syntax); if self .screen_row(position) .is_none_or(|screen| screen > target) { self.top = top; self.fill(diff, file, paint_height, syntax); break; } } } } } } fn diff_screen_rows<'a>( display_len: usize, top: usize, height: u16, wrapped: Option<&'a [WrappedDiffRow]>, ) -> impl Iterator; 2]>)> + 'a { (0..usize::from(height)).filter_map(move |screen| { if let Some(rows) = wrapped { rows.get(screen) .filter(|row| row.scope == [false; 2]) .map(|row| { ( screen, row.position.display, (!row.unwrapped).then_some(row.segments), ) }) } else { (top + screen < display_len).then_some((screen, top + screen, None)) } }) } struct DiffSyntaxViewport<'a> { diff: &'a DiffState, top: usize, areas: [Rect; 2], wrapped: Option<&'a [WrappedDiffRow]>, } #[derive(Default)] struct DiffSyntaxCache { key: Option<(u64, usize, Option)>, revision: u64, before_revision: u64, before: SyntaxCache, after: SyntaxCache, before_rows: Vec>, after_rows: Vec>, } impl DiffSyntaxCache { fn scopes<'a>( &'a self, diff: &DiffState, display: usize, ) -> [Option>; 2] { [Focus::Before, Focus::After].map(|focus| { let source = diff.display_source_row(focus, display)?; let syntax = if focus == Focus::Before { &self.before } else { &self.after }; syntax.scope_at(source) }) } fn labels( &self, diff: &DiffState, position: DiffWrapPosition, scopes: [Option>; 2], sticky: [bool; 2], ) -> [[bool; 2]; 2] { let show = [Focus::Before, Focus::After].map(|focus| { let side = usize::from(focus == Focus::After); if scopes[side].is_none() || (!sticky[side] && position.continuation > 0) { return false; } if sticky[side] { return true; } // Alignment holes do not end a scope; only an omitted excerpt resets it. let previous = (0..position.display) .rev() .take_while(|&display| { !matches!(diff.display_rows[display], DisplayRow::Fold { .. }) }) .find_map(|display| diff.display_source_row(focus, display)); let syntax = if side == 0 { &self.before } else { &self.after }; scopes[side] != previous.and_then(|source| syntax.scope_at(source)) }); if diff.view_mode == ViewMode::Unified && scopes[0].map(|scope| scope.label) != scopes[1].map(|scope| scope.label) { [[show[0], false], [false, show[1]]] } else { [show, [false; 2]] } } fn update( &mut self, comparison_id: u64, file_index: usize, file: &ChangedFile, after: &[String], live_revision: Option, visible: Option>, ) { let key = Some((comparison_id, file_index, live_revision)); if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) { self.before_revision = self .before_revision .checked_add(1) .expect("diff syntax revisions exhausted"); } if self.key != key { self.key = key; self.revision = self .revision .checked_add(1) .expect("diff syntax revisions exhausted"); } if let Some(DiffSyntaxViewport { diff, top, areas, wrapped, }) = visible { diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows, wrapped); diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows, wrapped); self.before.update_diff( &file.before, Some(file.old_path.as_deref().unwrap_or(&file.path)), self.before_revision, file.before_eof, Some(&self.before_rows), ); self.after.update_diff( after, Some(&file.path), self.revision, file.after_eof, Some(&self.after_rows), ); } else { self.before.update_diff( &file.before, Some(file.old_path.as_deref().unwrap_or(&file.path)), self.before_revision, file.before_eof, None, ); self.after .update_diff(after, Some(&file.path), self.revision, file.after_eof, None); } } } fn diff_visible_rows( diff: &DiffState, top: usize, pane: Rect, before: bool, ranges: &mut Vec>, wrapped: Option<&[WrappedDiffRow]>, ) { ranges.clear(); let height = if pane.width >= 4 && pane.height >= 4 { pane.height - 2 } else { pane.height }; for (_, display, _) in diff_screen_rows(diff.display_rows.len(), top, height, wrapped) { let source = diff.display_source_row(if before { Focus::Before } else { Focus::After }, display); if ranges .last() .is_some_and(|range| source.is_some_and(|source| range.contains(&source))) { continue; } if let Some(source) = source { if let Some(last) = ranges.last_mut() && last.end == source { last.end += 1; } else { ranges.push(source..source + 1); } } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DiffColors { row: [Color; 2], character: [Color; 2], } impl DiffColors { fn new(theme: Option) -> Self { let Some(theme) = theme else { return Self { row: [Color::Reset; 2], character: [Color::Reset; 2], }; }; let blend = |tint: [u8; 3], weight: u16| { let channels = std::array::from_fn::<_, 3, _>(|index| { ((u16::from(theme.background[index]) * (100 - weight) + u16::from(tint[index]) * weight + 50) / 100) as u8 }); Color::Rgb(channels[0], channels[1], channels[2]) }; Self { row: [blend(theme.red, 18), blend(theme.green, 18)], character: [blend(theme.red, 38), blend(theme.green, 38)], } } } impl Default for DiffColors { fn default() -> Self { Self::new(None) } } #[derive(Default)] struct WheelPanels { workspace_managed: bool, caret: Option<(Rect, Position)>, input: Option, notification: Option, completion: Option, hover: Option<(Rect, usize)>, hover_syntax: SyntaxCache, files: Option<(Rect, usize, usize)>, manual_files: Option, picker_scroll: PickerScroll, diff: Option, diff_colors: DiffColors, diff_syntax: Option, previous_diff: Option, diff_wrap: Option, } struct PickerScroll { distance: Option, files: Option, completion: Option, palette: Option, revisions: Option, } impl Default for PickerScroll { fn default() -> Self { Self { distance: Some(0), files: None, completion: None, palette: None, revisions: None, } } } #[derive(Default)] struct PickerViewport { key: u64, top: usize, } impl PickerViewport { fn follow( viewport: &mut Option, key: u64, selected: usize, height: usize, row_height: usize, distance: Option, ) -> usize { let viewport = viewport.get_or_insert_with(Self::default); if viewport.key != key { viewport.key = key; viewport.top = 0; } if height < row_height { return viewport.top; } let margin = scroll_margin(height, distance); let top = scroll_offset( viewport.top.saturating_mul(row_height), selected.saturating_mul(row_height), height, distance, ); let first = selected.saturating_sub(margin.div_ceil(row_height)); let last = selected .saturating_add(1) .saturating_sub((height - margin) / row_height); viewport.top = if margin == height / 2 || last > first { selected.saturating_sub((height / 2) / row_height) } else { top.div_ceil(row_height).clamp(last, first) }; viewport.top } } fn file_picker_key(picker: &FilePicker) -> u64 { let mut key = std::collections::hash_map::DefaultHasher::new(); ( &picker.root, &picker.query, picker.presentation as u8, picker.view as u8, picker.viewport_revision(), ) .hash(&mut key); key.finish() } struct FileWheelOffset { top: usize, root: std::path::PathBuf, query: String, state: (usize, u64), } impl FileWheelOffset { fn state(picker: &FilePicker) -> (usize, u64) { (picker.selected, file_picker_key(picker)) } fn matches(&self, picker: &FilePicker, key: u64) -> bool { self.state == (picker.selected, key) && self.root == picker.root && self.query == picker.query } } impl WheelPanels { fn begin_frame(&mut self) { if self.files.is_none() { self.manual_files = None; self.picker_scroll.files = None; } self.hover = None; self.caret = None; self.input = None; self.notification = None; self.completion = None; self.files = None; self.previous_diff = self.diff.take(); } fn follow_files(&mut self) { self.manual_files = None; } fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize { let key = FileWheelOffset::state(picker).1; if self .manual_files .as_ref() .is_some_and(|offset| !offset.matches(picker, key)) { self.manual_files = None; } let count = picker.entries().len(); let following = PickerViewport::follow( &mut self.picker_scroll.files, key, picker.selected.min(count.saturating_sub(1)), height, 1, self.picker_scroll.distance, ); let top = self.manual_files.as_mut().map_or(following, |offset| { offset.top = offset.top.min(count.saturating_sub(height.max(1))); offset.top }); if let Some(viewport) = &mut self.picker_scroll.files { viewport.top = top; } self.files = Some((panel, height, top)); top } // A horizontal report over a popup must not pan the underlying buffer. fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { let Some(kind) = wheel_kind(&mouse) else { return false; }; if session.editor().command_output().is_some() { let input = match kind { MouseEventKind::ScrollUp => Input::Up, MouseEventKind::ScrollDown => Input::Down, MouseEventKind::ScrollLeft => Input::Left, MouseEventKind::ScrollRight => Input::Right, _ => unreachable!(), }; for _ in 0..3 { session.handle(input.clone()); } return true; } let horizontal = matches!( kind, MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight ); let down = matches!( kind, MouseEventKind::ScrollDown | MouseEventKind::ScrollRight ); let position = Position::new(mouse.column, mouse.row); if self.input.is_some_and(|area| area.contains(position)) || self .notification .is_some_and(|area| area.contains(position)) { return true; } if horizontal && (session.editor().palette().is_some() || session.editor().startup_diagnostic().is_some() || self.completion.is_some_and(|area| area.contains(position)) || self.hover.is_some_and(|(area, _)| area.contains(position)) || self .files .is_some_and(|(area, _, _)| area.contains(position))) { return true; } let editor = session.editor(); if editor.palette().is_some() || editor.startup_diagnostic().is_some() { return true; } let position = ratatui::layout::Position::new(mouse.column, mouse.row); if editor.completion().is_some() && self .completion .is_some_and(|panel| panel.contains(position)) { return true; } if let Some(hover) = editor.hover() && let Some((panel, height)) = self.hover && panel.contains(position) { if height > 0 { let top = hover.top; let target = if down { top.saturating_add(3) } else { top.saturating_sub(3) } .min(hover.lines().len().saturating_sub(height)); let input = if target < top { Input::Up } else { Input::Down }; for _ in 0..top.abs_diff(target) { session.handle(input.clone()); } } return true; } let pane = session.editor().pane_id(); let floating_files = editor.file_picker().is_some_and(|picker| { picker.presentation == FilePresentation::Floating && self .files .is_some_and(|(area, _, _)| area.contains(position)) }); if !floating_files && self.diff.is_some() && self.scroll_comparison(session, mouse, pane) { return true; } let editor = session.editor(); if let Some(picker) = editor.file_picker() && let Some((panel, height, drawn_top)) = self.files && panel.contains(position) { if height > 0 { let key = file_picker_key(picker); if self .manual_files .as_ref() .is_some_and(|offset| !offset.matches(picker, key)) { self.manual_files = None; } let offset = self.manual_files.get_or_insert_with(|| FileWheelOffset { top: drawn_top, root: picker.root.clone(), query: picker.query.clone(), state: (picker.selected, key), }); offset.top = if down { offset.top.saturating_add(3) } else { offset.top.saturating_sub(3) } .min(picker.entries().len().saturating_sub(height)); } return true; } false } fn scroll_comparison( &mut self, session: &mut EditorSession, mouse: MouseEvent, pane: usize, ) -> bool { let Some(kind) = wheel_kind(&mouse) else { return false; }; let horizontal = matches!( kind, MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight ); let down = matches!( kind, MouseEventKind::ScrollDown | MouseEventKind::ScrollRight ); let Some(focus) = self.diff.as_ref().and_then(|geometry| { geometry.regions.iter().find_map(|(area, focus)| { area.contains(Position::new(mouse.column, mouse.row)) .then_some(*focus) }) }) else { return false; }; let (wrapping, wrapped_viewport) = session.editor_mut().with_render_pane(pane, |editor| { let wrapping = editor.word_wrap(); let viewport = if !horizontal && wrapping && focus != Focus::Files { editor.diff().and_then(|diff| { let file = diff.comparison.as_ref()?.files.get(diff.file)?; if !file.loaded { return None; } let id = diff.comparison_id?; let wrap = self.diff_wrap.as_mut()?; for _ in 0..3 { wrap.top = wrap.step(diff, file, wrap.top, down); } Some(( id, diff.file, wrap.top.display, diff.left_before, diff.left_after, )) }) } else { None }; (wrapping, viewport) }); if horizontal && !wrapping && focus != Focus::Files { let width = self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; if width > 0 { session.scroll_diff_horizontal(focus, down, usize::from(width)); } } else if let Some((id, file, top, before, after)) = wrapped_viewport { session.scroll_diff(focus, down); session.set_diff_viewport(id, file, top, before, after); } else if !horizontal { for _ in 0..3 { session.scroll_diff(focus, down); } } true } } fn draw_completion( frame: &mut Frame, completion: &Completion, area: Rect, caret: Position, panels: &mut WheelPanels, ) -> Option { if area.width == 0 || area.height == 0 || !area.contains(caret) { return None; } let count = completion.items.len().saturating_add(1); let selected = completion.selected.min(count - 1); let desired_height = count.min(8) as u16; let below = area.bottom() - caret.y - 1; let above = caret.y - area.y; let downward = below >= desired_height || below >= above; let height = desired_height.min(if downward { below } else { above }.max(1)); let mut key = std::collections::hash_map::DefaultHasher::new(); (completion.items.as_ptr(), completion.items.len()).hash(&mut key); let first = PickerViewport::follow( &mut panels.picker_scroll.completion, key.finish(), selected, usize::from(height), 1, panels.picker_scroll.distance, ); let lines: Vec = (first..count) .take(usize::from(height)) .map(|index| { let label = if index == 0 { "Original text".to_owned() } else { let item = &completion.items[index - 1]; let mut label = display_text(&item.label); if let Some(detail) = &item.detail { label.push_str(" · "); label.push_str(&display_text(detail)); } label }; let style = if index == selected { Style::default().fg(Color::Black).bg(Color::Cyan) } else { Style::default().fg(Color::Gray).bg(Color::Black) }; Line::styled( visible_line(&label, 0, usize::from(area.width.min(60))), style, ) }) .collect(); let width = lines.iter().map(Line::width).max().unwrap_or(1).max(1) as u16; let panel = Rect::new( caret.x.min(area.right() - width), if below == 0 && above == 0 { caret.y } else if downward { caret.y + 1 } else { caret.y - height }, width, height, ); frame.render_widget(Clear, panel); frame.render_widget( Paragraph::new(lines).style(Style::default().fg(Color::Gray).bg(Color::Black)), panel, ); Some(panel) } fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) { if area.width == 0 || area.height == 0 { return; } let [_, column, _] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(area.width.min(100)), Constraint::Fill(1), ]) .areas(area); let [_, panel, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length( area.height .min(hover.lines().len().saturating_add(3).min(40) as u16), ), Constraint::Fill(1), ]) .areas(column); frame.render_widget(Clear, panel); let bordered = panel.height >= 4 && panel.width >= 4; let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(Color::Cyan)) .title(" Hover "); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let [text_area, help_area] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 3)), ]) .areas(content); panels.hover = Some((panel, usize::from(text_area.height))); let lines: Vec = hover .lines() .skip(hover.top) .take(usize::from(text_area.height)) .map(|line| { Line::from(visible_line( line, display_column(line, hover.left), usize::from(text_area.width), )) }) .collect(); frame.render_widget(Paragraph::new(lines), text_area); panels .hover_syntax .update_text(hover.text(), std::path::Path::new("hover.md")); let width = usize::from(text_area.width); for (row, line) in hover .lines() .enumerate() .skip(hover.top) .take(usize::from(text_area.height)) { let spans = panels.hover_syntax.line(row); if spans.is_empty() { continue; } let left = display_column(line, hover.left); let right = left.saturating_add(width); let mut column = 0; let mut span_index = 0; for (byte, grapheme) in line.grapheme_indices(true) { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; while span_index < spans.len() && spans[span_index].end_byte <= byte { span_index += 1; } if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte) && end > left && (grapheme == "\t" || (column >= left && end <= right)) { let style = syntax_style(span.kind); let y = text_area.y + (row - hover.top) as u16; for cell in column.max(left)..end.min(right) { frame.buffer_mut()[(text_area.x + (cell - left) as u16, y)].set_style(style); } } column = end; } } frame.render_widget( Paragraph::new("Arrows/Ctrl+h/j/k/l scroll · Esc close") .style(Style::default().fg(Color::DarkGray)), help_area, ); } fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option) { if area.width == 0 || area.height == 0 { return (area, None); } let horizontal = matches!(side, DockSide::Left | DockSide::Right); let available = if horizontal { area.width } else { area.height }; if !focused && available < if horizontal { 24 } else { 8 } { return (area, None); } let extent = if available < if horizontal { 20 } else { 6 } { available } else if horizontal { (available / 3).clamp(12, 38).min(available - 8) } else { (available / 3).clamp(4, 12).min(available - 2) }; let leading = matches!(side, DockSide::Left | DockSide::Top); let constraints = if leading { [Constraint::Length(extent), Constraint::Fill(1)] } else { [Constraint::Fill(1), Constraint::Length(extent)] }; let [first, second] = if horizontal { Layout::horizontal(constraints).areas(area) } else { Layout::vertical(constraints).areas(area) }; if leading { (second, Some(first)) } else { (first, Some(second)) } } fn display_text(text: &str) -> String { text.chars() .map(|ch| if ch.is_control() { '�' } else { ch }) .collect() } fn draw_file_list( frame: &mut Frame, picker: &FilePicker, panel: Rect, navigation_hint: &str, panels: &mut WheelPanels, changed_files: Option<(&[ChangedFile], &BTreeSet)>, ) { if panel.width == 0 || panel.height == 0 { return; } let color = if picker.focused { Color::Cyan } else { Color::DarkGray }; let bordered = panel.width >= 4 && panel.height >= 4; let browsing = picker.view == FileView::Tree && !picker.searching; let title = match (picker.presentation, picker.view, browsing) { (FilePresentation::Floating, FileView::List, _) => " Files · List ", (FilePresentation::Docked, FileView::List, _) => " Explorer · List ", (FilePresentation::Floating, FileView::Tree, true) => " Files · Tree NORMAL ", (FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree NORMAL ", (FilePresentation::Floating, FileView::Tree, false) => " Files · Tree INSERT ", (FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree INSERT ", }; let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(color)) .title(title); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let show_root = content.height >= 4 && !picker.root.as_os_str().is_empty(); let show_error = picker.error.is_some() && content.height >= 2; let show_help = changed_files.is_none() && content.height >= 5 + u16::from(show_error); let root = show_root.then(|| { Paragraph::new(display_text(&picker.root.to_string_lossy())) .style(Style::default().fg(Color::DarkGray)) .wrap(Wrap { trim: false }) }); // Reserve the query, optional notices/footer, and at least one file row. let root_rows = root.as_ref().map_or(0, |root| { root.line_count(content.width).min(usize::from( content .height .saturating_sub(2 + u16::from(show_error) + u16::from(show_help)), )) as u16 }); let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([ Constraint::Length(1), Constraint::Length(root_rows), Constraint::Length(u16::from(show_error)), Constraint::Fill(1), Constraint::Length(u16::from(show_help)), ]) .areas(content); let first = panels.file_top(picker, panel, usize::from(results_area.height)); let query = if browsing && picker.query.is_empty() { "i filter · ^U clear · ^T view".into() } else if browsing { format!("filter: {} · ^U clear", display_text(&picker.query)) } else { format!("> {}", display_text(&picker.query)) }; let query_width = display_column(&query, usize::MAX); let width = usize::from(query_area.width); let offset = if browsing { 0 } else { query_width.saturating_sub(width.saturating_sub(1)) }; frame.render_widget( Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg( if picker.focused { Color::Yellow } else { Color::DarkGray }, )), query_area, ); if picker.focused && query_area.width > 0 && query_area.height > 0 && (!browsing || picker.entries().len() == 0 || results_area.height == 0) { let cursor_x = if browsing { 0 } else { query_width - offset }; frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y)); } if let Some(root) = root { frame.render_widget(root, root_area); } if show_error { frame.render_widget( Paragraph::new(display_text(picker.error.as_deref().unwrap())) .style(Style::default().fg(Color::Red)), error_area, ); } let entries = picker.entries(); if entries.len() == 0 { frame.render_widget( Paragraph::new(if picker.query.is_empty() { "No project files" } else { "No matching files" }), results_area, ); } else if results_area.height > 0 { let count = usize::from(results_area.height); let selected_index = picker.selected.min(entries.len() - 1); let lines: Vec = entries .enumerate() .skip(first) .take(count) .map(|(index, entry)| { let selected = index == selected_index; let mut style = if entry.is_dir { Style::default().fg(Color::Cyan) } else { Style::default() }; if selected { style = style .bg(if picker.focused { Color::Blue } else { Color::DarkGray }) .add_modifier(Modifier::BOLD); } let changed = entry .catalog_index .and_then(|index| changed_files.and_then(|(files, _)| files.get(index))); let status = changed.map(|file| file.status).or(entry.status); let old_path = changed .and_then(|file| file.old_path.as_deref()) .or(entry.old_path); let mut spans = Vec::with_capacity(8); let reviewed = changed_files.is_some_and(|(_, reviewed)| { if entry.is_dir { if entry.expanded { return false; } let mut descendants = picker.descendant_catalog_indices(entry.path).peekable(); descendants.peek().is_some() && descendants.all(|index| reviewed.contains(&index)) } else { changed.is_some() && entry .catalog_index .is_some_and(|index| reviewed.contains(&index)) } }); if changed_files.is_none() { spans.push(Span::raw(if selected { ">" } else { " " })); spans.push(Span::raw(" ")); } spans.push(Span::raw(if picker.view == FileView::Tree { " ".repeat(entry.depth * 2) } else { String::new() })); if changed_files.is_some() { spans.push(Span::styled( if reviewed { "✓" } else { " " }, Style::default().fg(Color::Green), )); spans.push(Span::raw(" ")); } if picker.is_conflicted(entry.path) { spans.push(Span::styled("C", Style::default().fg(Color::Yellow))); spans.push(Span::raw(" ")); } else if let Some(status) = status { let color = match status { FileStatus::Added => Color::Green, FileStatus::Removed => Color::Red, FileStatus::Modified => Color::Yellow, FileStatus::Renamed => Color::Cyan, }; spans.push(Span::styled( diff_status(status), Style::default().fg(color), )); spans.push(Span::raw(" ")); } else if changed_files.is_none() { spans.push(Span::raw(" ")); } if status == Some(FileStatus::Renamed) && let Some(old_path) = old_path { spans.push(Span::raw(display_text(&old_path.to_string_lossy()))); spans.push(Span::raw(" -> ")); spans.push(Span::raw(display_text( &changed .map_or(entry.path, |file| file.path.as_path()) .to_string_lossy(), ))); } else { spans.push(Span::raw(entry.label)); if entry.is_dir && picker.view == FileView::Tree { spans.push(Span::raw("/")); } } Line::from(spans).style(style) }) .collect(); frame.render_widget(Paragraph::new(lines), results_area); if browsing && picker.focused && results_area.width > 0 && selected_index >= first && selected_index - first < count { frame.set_cursor_position(( results_area.x, results_area.y + (selected_index - first) as u16, )); } } if !show_help { return; } let help = if picker.focused { let controls = match (picker.presentation, browsing) { (FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T", (FilePresentation::Docked, true) => "Enter · i filter · Esc/Tab edit · ^W · ^R · ^T", (_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T", (FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T", (FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T", }; format!("^U clear · {navigation_hint} · {controls}") } else { "Editor focus · invoke explorer to browse".into() }; frame.render_widget( Paragraph::new(help).style(Style::default().fg(Color::DarkGray)), help_area, ); } fn draw_palette( frame: &mut Frame, palette: &Palette, area: Rect, mode: Mode, navigation_hint: &str, panels: &mut WheelPanels, ) { if area.width == 0 || area.height == 0 { return; } let panel = if palette.buffers { floating_panel(area) } else { let [_, column, _] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(area.width.min(100)), Constraint::Fill(1), ]) .areas(area); let [_, panel, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(area.height.min(16)), Constraint::Fill(1), ]) .areas(column); panel }; frame.render_widget(Clear, panel); let bordered = panel.height >= 4 && panel.width >= 4; let padding = u16::from(palette.buffers && panel.width.saturating_sub(2 * u16::from(bordered)) >= 3); let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(if palette.buffers { Style::default().fg(Color::Cyan) } else { Style::default() }) .padding(ratatui::widgets::Padding::horizontal(padding)) .title(if palette.buffers { " Buffers ".into() } else { format!(" Commands · {mode} ") }); let content = if bordered { block.inner(panel) } else { panel.inner(ratatui::layout::Margin::new(padding, 0)) }; if bordered { frame.render_widget(block, panel); } let [query_area, results_area, help_area] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 4)), ]) .areas(content); let query = format!("> {}", palette.query); let query_width = display_column(&query, usize::MAX); let width = usize::from(query_area.width); let offset = query_width.saturating_sub(width.saturating_sub(1)); frame.render_widget( Paragraph::new(visible_line(&query, offset, width)) .style(Style::default().fg(Color::Yellow)), query_area, ); frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); if palette.entries.is_empty() { panels.picker_scroll.palette = None; frame.render_widget( Paragraph::new(if palette.buffers { "No matching buffers" } else { "No matching commands" }), results_area, ); } else if results_area.height > 0 { let row_height = if palette.buffers { 1 } else { usize::from(results_area.height >= 2) + 1 }; let count = usize::from(results_area.height) / row_height; let mut key = std::collections::hash_map::DefaultHasher::new(); (&palette.query, palette.buffers).hash(&mut key); (palette.entries.as_ptr(), palette.entries.len()).hash(&mut key); let first = PickerViewport::follow( &mut panels.picker_scroll.palette, key.finish(), palette.selected.min(palette.entries.len() - 1), usize::from(results_area.height), row_height, panels.picker_scroll.distance, ); let id_width = palette .entries .last() .and_then(|entry| entry.buffer.as_ref()) .map_or(1, |buffer| buffer.id.ilog10() as usize + 1); let mut lines = Vec::with_capacity(count * row_height); for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) { let mut style = Style::default(); if !entry.enabled { style = style.add_modifier(Modifier::DIM); } if index == palette.selected { style = style.bg(Color::Blue).add_modifier(Modifier::BOLD); } if let Some(buffer) = &entry.buffer { lines.push( Line::from(vec![ Span::raw(if index == palette.selected { "> " } else { " " }), Span::styled( if buffer.active { "* " } else { " " }, Style::default().fg(Color::Cyan), ), Span::raw(format!("{:>id_width$}", buffer.id)), Span::styled( if buffer.dirty { "+ " } else { " " }, Style::default().fg(Color::Yellow), ), Span::raw(display_path(std::path::Path::new(&entry.label))), ]) .style(style), ); continue; } let bindings = if entry.bindings.is_empty() { "unbound".into() } else { entry.bindings.join(", ") }; let modes = entry .modes .iter() .map(ToString::to_string) .collect::>() .join("/"); let metadata = format!("{} | {bindings} | {modes}", entry.owner); let label = format!( "{} {} ({})", if index == palette.selected { ">" } else { " " }, entry.label, entry.name ); if row_height == 2 { lines.push(Line::from(label).style(style)); lines.push(Line::from(format!(" {metadata}")).style(style)); } else { lines.push(Line::from(format!("{label} | {metadata}")).style(style)); } } frame.render_widget(Paragraph::new(lines), results_area); } frame.render_widget( Paragraph::new(if palette.buffers { format!("{navigation_hint} · Enter open · Esc/^P cancel · ^U clear") } else { format!("{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel") }) .style(if palette.buffers { Style::default().fg(Color::DarkGray) } else { Style::default() }), help_area, ); } fn paint_syntax_line( frame: &mut Frame, source: &str, spans: &[SyntaxSpan], area: Rect, left: usize, ) { if spans.is_empty() || area.width == 0 || area.height == 0 { return; } let right = left.saturating_add(usize::from(area.width)); let mut span_index = 0; let mut column = 0; for (byte, grapheme) in source.grapheme_indices(true) { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) + grapheme.chars().filter(|ch| ch.is_control()).count() }; let end = column + cells; while span_index < spans.len() && spans[span_index].end_byte <= byte { span_index += 1; } if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte) && end > left && (grapheme == "\t" || (column >= left && end <= right)) { let style = syntax_style(span.kind); for cell in column.max(left)..end.min(right) { frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)].set_style(style); } } column = end; } } fn syntax_style(kind: SyntaxKind) -> Style { Style::default().fg(match kind { SyntaxKind::Comment => Color::DarkGray, SyntaxKind::String => Color::Green, SyntaxKind::Keyword => Color::Magenta, SyntaxKind::Number => Color::Yellow, SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan, SyntaxKind::Function => Color::LightBlue, }) } fn semantic_style(token: &SemanticToken) -> Style { use SemanticTokenKind::*; let readonly = token.modifiers & SemanticTokenModifier::Readonly as u16 != 0; let color = match token.kind { Namespace => Color::Gray, Type | Class | Enum | Interface | Struct | TypeParameter => Color::Cyan, Function | Method | Event | Macro => Color::LightBlue, Parameter | Variable => Color::LightCyan, Property => Color::Blue, EnumMember | Number => Color::Yellow, Keyword | Modifier | Operator | Decorator => Color::Magenta, String | Regexp => Color::Green, Comment => Color::DarkGray, }; let mut style = Style::default().fg(color); if readonly && matches!(token.kind, Parameter | Variable | Property) { style = style.add_modifier(ratatui::style::Modifier::ITALIC); } if token.modifiers & (SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Definition as u16) != 0 { style = style.add_modifier(ratatui::style::Modifier::BOLD); } if token.kind == Comment && token.modifiers & SemanticTokenModifier::Documentation as u16 != 0 { style = style.add_modifier(ratatui::style::Modifier::ITALIC); } if token.modifiers & SemanticTokenModifier::Deprecated as u16 != 0 { style = style.add_modifier(ratatui::style::Modifier::CROSSED_OUT); } style } #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] #[path = "../tests/unit/tui.rs"] mod tests;