Files
vamp/src/tui.rs
T

6523 lines
230 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
struct HookState {
previous: Option<PanicHook>,
active: bool,
}
/// Owns terminal modes and panic cleanup through application startup and shutdown.
pub struct TerminalSession {
hook: Arc<Mutex<HookState>>,
}
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<ManualViewport>,
text_width: usize,
horizontal_extent: Option<(usize, u64, usize)>,
wrap: WrapCache,
continuations: BTreeMap<usize, usize>,
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<Cursor>,
selections: Vec<Selection>,
}
#[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<VisualRow> {
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<VisualRow> {
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>) -> usize {
distance.unwrap_or(size / 2).min(size / 2)
}
fn scroll_offset(top: usize, cursor: usize, size: usize, distance: Option<usize>) -> 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<usize, (usize, usize)>,
syntax: BTreeMap<usize, SyntaxCache>,
document: DocumentViewport,
document_buffer: Option<usize>,
documents: BTreeMap<usize, DocumentViewport>,
panels: WheelPanels,
pane: Option<usize>,
parked: BTreeMap<usize, PaneViewport>,
rects: Vec<(usize, Rect)>,
theme: Option<ThemeColors>,
diff_visible: bool,
history: BTreeMap<HistoryKey, HistoryDiff>,
history_key: Option<HistoryKey>,
}
#[derive(Default)]
struct PaneViewport {
offsets: BTreeMap<usize, (usize, usize)>,
document: DocumentViewport,
document_buffer: Option<usize>,
documents: BTreeMap<usize, DocumentViewport>,
panels: WheelPanels,
}
type HistoryKey = (usize, [usize; 2], Option<usize>);
fn history_key(resolver: &ResolverWorkspace) -> Option<HistoryKey> {
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<WrappedDiffRow>; 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<usize>| {
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<ThemeColors>) {
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<Option<Event>>,
) -> io::Result<(bool, Option<Event>)> {
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<MouseEventKind> {
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<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
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<Option<Event>> {
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<Option<Event>>,
) -> io::Result<Option<Event>> {
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<Input> {
#[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<usize>,
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<Option<std::path::PathBuf>> = 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<ChromeItem<'a>> {
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::<usize>() + 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<ChromeItem<'_>>; 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<ChromeItem<'a>>; 3] {
[&line.left, &line.center, &line.right].map(|names| chrome_items(editor, line, names, syntax))
}
fn chrome_visible(editor: &Editor, groups: &[Vec<ChromeItem<'_>>; 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<Line> = 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<Line> = 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<Rect> {
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<Rect> {
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<usize>,
}
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<char, char>,
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<usize>,
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<Position> {
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<Span> = 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<Rect>,
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<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
highlight_trailing_whitespace: bool,
whitespace_characters: Option<&BTreeMap<char, char>>,
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<char, char>>,
whitespace_scope: WhitespaceScope,
review_comments: impl Iterator<Item = &'a crate::review::Comment> + Clone,
selected_review: Option<u64>,
) -> 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::<String>(),
)
} 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<Line> = 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<crate::wrap::Segment>; 2],
final_segments: [bool; 2],
scope: [bool; 2],
unwrapped: bool,
}
#[derive(Default)]
struct DiffWrapViewport {
key: Option<(u64, usize, Option<u64>)>,
view_mode: Option<ViewMode>,
revision: u64,
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
widths: [usize; 2],
lefts: [usize; 2],
heights: [usize; 2],
visible_names: [Vec<Point>; 2],
top: DiffWrapPosition,
rows: Vec<WrappedDiffRow>,
}
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<DiffWrapPosition> {
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<usize> {
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<usize>,
distance: Option<usize>,
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<Item = (usize, usize, Option<[Option<crate::wrap::Segment>; 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<u64>)>,
revision: u64,
before_revision: u64,
before: SyntaxCache,
after: SyntaxCache,
before_rows: Vec<std::ops::Range<usize>>,
after_rows: Vec<std::ops::Range<usize>>,
}
impl DiffSyntaxCache {
fn scopes<'a>(
&'a self,
diff: &DiffState,
display: usize,
) -> [Option<crate::syntax::ScopeContext<'a>>; 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<crate::syntax::ScopeContext<'_>>; 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<u64>,
visible: Option<DiffSyntaxViewport<'_>>,
) {
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<std::ops::Range<usize>>,
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<ThemeColors>) -> 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<Rect>,
notification: Option<Rect>,
completion: Option<Rect>,
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
picker_scroll: PickerScroll,
diff: Option<DiffGeometry>,
diff_colors: DiffColors,
diff_syntax: Option<DiffSyntaxCache>,
previous_diff: Option<DiffGeometry>,
diff_wrap: Option<DiffWrapViewport>,
}
struct PickerScroll {
distance: Option<usize>,
files: Option<PickerViewport>,
completion: Option<PickerViewport>,
palette: Option<PickerViewport>,
revisions: Option<PickerViewport>,
}
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<Self>,
key: u64,
selected: usize,
height: usize,
row_height: usize,
distance: Option<usize>,
) -> 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<Rect> {
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<Line> = (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<Line> = 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<Rect>) {
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<usize>)>,
) {
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<Line> = 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::<Vec<_>>()
.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;