6576 lines
232 KiB
Rust
6576 lines
232 KiB
Rust
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 · "),
|
||
"changes" => {
|
||
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 Some((added, removed)) = file.line_counts()
|
||
{
|
||
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()
|
||
}),
|
||
"diff" => {
|
||
let Some(comparison) = editor
|
||
.workspace_diff()
|
||
.filter(|diff| diff.phase == Phase::View)
|
||
.and_then(|diff| diff.comparison.as_ref())
|
||
else {
|
||
continue;
|
||
};
|
||
match comparison
|
||
.files
|
||
.iter()
|
||
.try_fold((0, 0), |(added, removed), file| {
|
||
file.line_counts().map(|(a, r)| (added + a, removed + r))
|
||
}) {
|
||
Some((added, removed)) => Cow::Owned(format!("Diff +{added} -{removed}")),
|
||
None => Cow::Borrowed("Diff …"),
|
||
}
|
||
}
|
||
"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];
|
||
if !wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len() {
|
||
paint_inline_blame(
|
||
frame,
|
||
label,
|
||
line,
|
||
Rect::new(viewport.x, viewport.y + screen_row as u16, width as u16, 1),
|
||
segment.start,
|
||
);
|
||
}
|
||
}
|
||
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_inline_blame(frame: &mut Frame, label: &str, line: &str, area: Rect, left: usize) {
|
||
let end = display_column(line, usize::MAX);
|
||
let start = end.saturating_sub(left).saturating_add(2);
|
||
let label_width =
|
||
UnicodeWidthStr::width(label).min(usize::from(area.width).saturating_sub(start));
|
||
if end >= left && label_width > 0 {
|
||
frame.render_widget(
|
||
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
|
||
Rect::new(area.x + start as u16, area.y, label_width as u16, 1),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 mut blame_area = 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));
|
||
}
|
||
}
|
||
if source == source_cursor.row
|
||
&& (segments.is_none()
|
||
|| wrapped.is_some_and(|rows| {
|
||
rows[screen].final_segments[usize::from(focus == Focus::After)]
|
||
}))
|
||
{
|
||
blame_area = Some((Rect::new(area.x, line_area.y, area.width, 1), left));
|
||
}
|
||
}
|
||
if cursor.is_some()
|
||
&& let Some((area, left)) = blame_area
|
||
&& let Some(label) = editor.line_blame()
|
||
{
|
||
paint_inline_blame(frame, label, &editor.lines()[source_cursor.row], area, left);
|
||
}
|
||
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!(
|
||
"{}{}",
|
||
if pull_requests { " " } else { " " },
|
||
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_map(|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
|
||
};
|
||
if !(unified || before == (focus == Focus::Before)) || comment.anchor.path != *path {
|
||
return None;
|
||
}
|
||
diff.review_anchor_range(&comment.anchor)
|
||
.map(|range| (comment, range))
|
||
});
|
||
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,
|
||
std::ops::Range<crate::plugin::TextPosition>,
|
||
),
|
||
> + 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));
|
||
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((comment, range)) = &selected_anchor {
|
||
let side = match comment.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| {
|
||
(range.start.row
|
||
..=range.end.row
|
||
- usize::from(range.end.column == 0 && range.end.row > range.start.row))
|
||
.contains(source_row)
|
||
}) {
|
||
let start = if source_row == range.start.row {
|
||
range.start.column
|
||
} else {
|
||
0
|
||
};
|
||
let end = if source_row == range.end.row {
|
||
range.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, range) 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| {
|
||
(range.start.row
|
||
..=range.end.row
|
||
- usize::from(
|
||
range.end.column == 0 && range.end.row > range.start.row,
|
||
))
|
||
.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;
|