Panics · P17 Editor: owner offsets ride the text's edits, stale ranges, rows and globs log instead of panicking
This commit is contained in:
1 parent
f0b7f779f6
commit
b29d66ccc0
13 files changed
+489
-83
No files matched your search
@@ -0,0 +1,83 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use super::state::Marks;
|
||||
|
||||
/// Where `offset` lands after `edits`, sorted and apart, each a replaced range and the length written; none when an edit replaced it.
|
||||
fn moved(offset: usize, edits: &[(Range<usize>, usize)]) -> Option<usize> {
|
||||
let mut at = offset as isize;
|
||||
for (range, written) in edits {
|
||||
if offset <= range.start {
|
||||
break;
|
||||
}
|
||||
if offset < range.end {
|
||||
return None;
|
||||
}
|
||||
at += *written as isize - range.len() as isize;
|
||||
}
|
||||
Some(at as usize)
|
||||
}
|
||||
|
||||
/// A range through `edits`; none when an edit cut into it.
|
||||
fn moved_range(range: &Range<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
|
||||
Some(moved(range.start, edits)?..moved(range.end, edits)?)
|
||||
}
|
||||
|
||||
/// Carries the owner's offsets through an edit of the text, dropping what it replaced.
|
||||
pub(crate) fn shift(marks: &mut Marks, edits: &[(Range<usize>, usize)]) {
|
||||
marks.diagnostics.retain_mut(|diagnostic| {
|
||||
moved_range(&diagnostic.range, edits)
|
||||
.map(|range| diagnostic.range = range)
|
||||
.is_some()
|
||||
});
|
||||
marks.backgrounds.retain_mut(|(range, _)| {
|
||||
moved_range(range, edits)
|
||||
.map(|moved| *range = moved)
|
||||
.is_some()
|
||||
});
|
||||
marks.hints.retain_mut(|hint| {
|
||||
moved(hint.offset, edits)
|
||||
.map(|at| hint.offset = at)
|
||||
.is_some()
|
||||
});
|
||||
marks.ghost = marks.ghost.take().and_then(|mut ghost| {
|
||||
ghost.offset = moved(ghost.offset, edits)?;
|
||||
Some(ghost)
|
||||
});
|
||||
}
|
||||
|
||||
/// Drops the owner's offsets when the whole text changes; they named the old one.
|
||||
pub(crate) fn clear(marks: &mut Marks) {
|
||||
marks.diagnostics.clear();
|
||||
marks.backgrounds.clear();
|
||||
marks.hints.clear();
|
||||
marks.ghost = None;
|
||||
}
|
||||
|
||||
/// Whether `range` runs forward between whole characters of `text`.
|
||||
pub(crate) fn fits(text: &str, range: &Range<usize>) -> bool {
|
||||
range.start <= range.end
|
||||
&& text.is_char_boundary(range.start)
|
||||
&& text.is_char_boundary(range.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fits, moved};
|
||||
|
||||
#[test]
|
||||
fn offsets_ride_their_edits() {
|
||||
let edits = [(2..4, 1), (6..6, 3)];
|
||||
let cut = 0..2;
|
||||
assert_eq!(moved(1, &edits), Some(1), "before every edit");
|
||||
assert_eq!(moved(2, &edits), Some(2), "at an edit's start");
|
||||
assert_eq!(moved(3, &edits), None, "inside what it replaced");
|
||||
assert_eq!(moved(5, &edits), Some(4), "past it, by its change");
|
||||
assert_eq!(
|
||||
moved(6, &edits),
|
||||
Some(5),
|
||||
"at an insertion, before what it adds"
|
||||
);
|
||||
assert_eq!(moved(8, &edits), Some(10));
|
||||
assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9)));
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,9 @@ impl OutputPanel {
|
||||
) -> Self {
|
||||
let channels: Vec<SharedString> = channels.into_iter().map(Into::into).collect();
|
||||
let channel = channel.into();
|
||||
assert!(
|
||||
channels.contains(&channel),
|
||||
"the shown channel is one of the channels"
|
||||
);
|
||||
if !channels.contains(&channel) {
|
||||
log::error!("output panel: {channel} is not among {channels:?}; none chosen");
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
channels,
|
||||
|
||||
+72
-1
@@ -1,8 +1,9 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{ClipboardItem, Context};
|
||||
use gpui::{ClipboardItem, Context, ScrollStrategy};
|
||||
|
||||
use super::{
|
||||
anchors,
|
||||
buffer::Buffer,
|
||||
cursor::{Motion, Selection, merged, replace_each},
|
||||
state::CodeEditor,
|
||||
@@ -39,6 +40,10 @@ impl CodeEditor {
|
||||
let before = self.snapshot();
|
||||
let primary = self.primary().range().start;
|
||||
let edits = joined(edits);
|
||||
let spans: Vec<(Range<usize>, usize)> = edits
|
||||
.iter()
|
||||
.map(|(range, text)| (range.clone(), text.len()))
|
||||
.collect();
|
||||
let lead = edits
|
||||
.iter()
|
||||
.rposition(|(range, _)| range.start <= primary)
|
||||
@@ -48,6 +53,7 @@ impl CodeEditor {
|
||||
carets.push(lead);
|
||||
self.selections = merged(carets);
|
||||
self.marked = None;
|
||||
anchors::shift(&mut self.marks, &spans);
|
||||
self.commit(before, typing, cx);
|
||||
self.reveal(true, cx);
|
||||
}
|
||||
@@ -424,3 +430,68 @@ fn joined(mut edits: Vec<(Range<usize>, String)>) -> Vec<(Range<usize>, String)>
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Selects these ranges, the last one primary.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<usize>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let chosen: Vec<Selection> = ranges
|
||||
.into_iter()
|
||||
.filter(|range| {
|
||||
let whole = self.whole(range);
|
||||
if !whole {
|
||||
log::error!(
|
||||
"code editor: no selection {range:?} in {} bytes",
|
||||
self.buffer.len()
|
||||
);
|
||||
}
|
||||
whole
|
||||
})
|
||||
.map(|range| Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
})
|
||||
.collect();
|
||||
if chosen.is_empty() {
|
||||
return;
|
||||
}
|
||||
if merged(chosen.clone()) == self.selections {
|
||||
return;
|
||||
}
|
||||
self.set_selections(chosen, cx);
|
||||
self.scroll
|
||||
.scroll_to_item(self.primary_row(), ScrollStrategy::Center);
|
||||
}
|
||||
|
||||
/// Replaces ranges of the text at once, as one undo step; read-only text refuses.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
edits: impl IntoIterator<Item = (Range<usize>, String)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits: Vec<(Range<usize>, String)> = edits.into_iter().collect();
|
||||
let mut starts: Vec<&Range<usize>> = edits.iter().map(|(range, _)| range).collect();
|
||||
starts.sort_by_key(|range| range.start);
|
||||
let apart = starts.windows(2).all(|pair| pair[0].end <= pair[1].start);
|
||||
let forward = edits.iter().all(|(range, _)| range.start <= range.end);
|
||||
if !apart || !forward || !edits.iter().all(|(range, _)| self.whole(range)) {
|
||||
log::error!(
|
||||
"code editor: edits {:?} do not fit {} bytes; none made",
|
||||
starts,
|
||||
self.buffer.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
/// Whether both ends of `range` fall between whole characters of the text.
|
||||
fn whole(&self, range: &Range<usize>) -> bool {
|
||||
let text = self.buffer.text();
|
||||
text.is_char_boundary(range.start) && text.is_char_boundary(range.end)
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -11,7 +11,7 @@ use crate::{
|
||||
|
||||
/// Whether a path passes comma-separated globs: some include pattern when there are any, and no exclude one.
|
||||
pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
|
||||
let patterns = |list: &str| -> Vec<regex::Regex> {
|
||||
let patterns = |list: &str| -> Vec<Option<regex::Regex>> {
|
||||
list.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|glob| !glob.is_empty())
|
||||
@@ -34,13 +34,20 @@ pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
|
||||
}
|
||||
}
|
||||
pattern.push('$');
|
||||
regex::Regex::new(&pattern).expect("an escaped glob compiles")
|
||||
regex::Regex::new(&pattern)
|
||||
.inspect_err(|error| {
|
||||
log::error!(
|
||||
"search filter: a glob of {} bytes matches nothing: {error}",
|
||||
pattern.len()
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (include, exclude) = (patterns(include), patterns(exclude));
|
||||
(include.is_empty() || include.iter().any(|glob| glob.is_match(path)))
|
||||
&& !exclude.iter().any(|glob| glob.is_match(path))
|
||||
(include.is_empty() || include.iter().flatten().any(|glob| glob.is_match(path)))
|
||||
&& !exclude.iter().flatten().any(|glob| glob.is_match(path))
|
||||
}
|
||||
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
+7
-4
@@ -82,17 +82,20 @@ impl CodeEditor {
|
||||
let Row::Line(line) = *row else {
|
||||
return None;
|
||||
};
|
||||
Some(self.offset_in(line, position.x, cx))
|
||||
self.offset_in(line, position.x, cx)
|
||||
}
|
||||
|
||||
/// The offset nearest window x on a line.
|
||||
pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> usize {
|
||||
/// The offset nearest window x on a line; none for a line the text has since lost.
|
||||
pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> Option<usize> {
|
||||
if line >= self.buffer.lines() {
|
||||
return None;
|
||||
}
|
||||
let text = self.row_text(line);
|
||||
let shown = Shown::new(text, self.notes(line, cx), Vec::new());
|
||||
let column = ((x - self.metrics.left) / self.metrics.advance)
|
||||
.round()
|
||||
.max(0.0) as usize;
|
||||
self.buffer.offset(line, shown.back(text, column))
|
||||
Some(self.buffer.offset(line, shown.back(text, column)))
|
||||
}
|
||||
|
||||
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
|
||||
|
||||
+6
-2
@@ -353,7 +353,9 @@ impl CodeEditor {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus, cx);
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let Some(at) = self.offset_in(line, event.position.x, cx) else {
|
||||
return;
|
||||
};
|
||||
let chosen = match event.click_count {
|
||||
2 => {
|
||||
let word = self.buffer.word_at(at);
|
||||
@@ -396,7 +398,9 @@ impl CodeEditor {
|
||||
if !self.dragging || !event.dragging() {
|
||||
return;
|
||||
}
|
||||
let at = self.offset_in(line, event.position.x, cx);
|
||||
let Some(at) = self.offset_in(line, event.position.x, cx) else {
|
||||
return;
|
||||
};
|
||||
let mut all = self.selections.clone();
|
||||
let primary = all.last_mut().expect("an editor keeps a cursor");
|
||||
if primary.head == at {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod anchors;
|
||||
mod buffer;
|
||||
mod chat;
|
||||
mod console;
|
||||
|
||||
+3
-3
@@ -150,14 +150,14 @@ impl CodeEditor {
|
||||
});
|
||||
}
|
||||
|
||||
/// The row the primary cursor draws on.
|
||||
/// The row the primary cursor draws on, or the fold header above it when a fold hides it.
|
||||
pub(crate) fn primary_row(&self) -> usize {
|
||||
let line = self.buffer.line_of(self.primary().head);
|
||||
let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded);
|
||||
rows(&self.buffer, &hide, &self.marks)
|
||||
.iter()
|
||||
.position(|row| *row == Row::Line(line))
|
||||
.expect("the primary cursor sits on a shown line")
|
||||
.rposition(|row| matches!(row, Row::Line(shown) if *shown <= line))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Scrolls the primary cursor into view: from below when moving down, from above when moving up.
|
||||
|
||||
+27
-52
@@ -1,11 +1,12 @@
|
||||
use std::{collections::BTreeSet, ops::Range, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, ScrollStrategy,
|
||||
SharedString, Subscription, Task, UniformListScrollHandle, Window,
|
||||
App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, SharedString,
|
||||
Subscription, Task, UniformListScrollHandle, Window,
|
||||
};
|
||||
|
||||
use super::{
|
||||
anchors,
|
||||
buffer::Buffer,
|
||||
cursor::{Selection, merged},
|
||||
decor::{CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint},
|
||||
@@ -129,6 +130,14 @@ impl Focusable for CodeEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mark the owner sent fits the text; one that does not is logged and dropped.
|
||||
fn kept(fits: bool, what: &str) -> bool {
|
||||
if !fits {
|
||||
log::error!("code editor: {what} lies off the text; dropped");
|
||||
}
|
||||
fits
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus = cx.focus_handle().tab_stop(true);
|
||||
@@ -291,6 +300,8 @@ impl CodeEditor {
|
||||
self.buffer = Buffer::new(text);
|
||||
self.selections = vec![Selection::caret(0)];
|
||||
self.folded.clear();
|
||||
(self.marked, self.composing) = (None, None);
|
||||
anchors::clear(&mut self.marks);
|
||||
self.commit(before, false, cx);
|
||||
}
|
||||
|
||||
@@ -310,37 +321,10 @@ impl CodeEditor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Selects these ranges, the last one primary.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
ranges: impl IntoIterator<Item = Range<usize>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let chosen: Vec<Selection> = ranges
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
assert!(
|
||||
range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end),
|
||||
"a selection covers whole characters of the text"
|
||||
);
|
||||
Selection {
|
||||
anchor: range.start,
|
||||
head: range.end,
|
||||
goal: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if merged(chosen.clone()) == self.selections {
|
||||
return;
|
||||
}
|
||||
self.set_selections(chosen, cx);
|
||||
self.scroll
|
||||
.scroll_to_item(self.primary_row(), ScrollStrategy::Center);
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
|
||||
pub fn set_diagnostics(&mut self, mut diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
|
||||
let text = self.buffer.text();
|
||||
diagnostics
|
||||
.retain(|diagnostic| kept(anchors::fits(text, &diagnostic.range), "a diagnostic"));
|
||||
if self.marks.diagnostics == diagnostics {
|
||||
return;
|
||||
}
|
||||
@@ -350,9 +334,11 @@ impl CodeEditor {
|
||||
|
||||
pub fn set_backgrounds(
|
||||
&mut self,
|
||||
ranges: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
mut ranges: Vec<(Range<usize>, gpui::Hsla)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let text = self.buffer.text();
|
||||
ranges.retain(|(range, _)| kept(anchors::fits(text, range), "a background"));
|
||||
if self.marks.backgrounds == ranges {
|
||||
return;
|
||||
}
|
||||
@@ -360,7 +346,9 @@ impl CodeEditor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_inlay_hints(&mut self, hints: Vec<InlayHint>, cx: &mut Context<Self>) {
|
||||
pub fn set_inlay_hints(&mut self, mut hints: Vec<InlayHint>, cx: &mut Context<Self>) {
|
||||
let text = self.buffer.text();
|
||||
hints.retain(|hint| kept(text.is_char_boundary(hint.offset), "an inlay hint"));
|
||||
self.marks.hints = hints;
|
||||
self.marks.hints.sort_by_key(|hint| hint.offset);
|
||||
cx.notify();
|
||||
@@ -373,7 +361,9 @@ impl CodeEditor {
|
||||
|
||||
/// Suggested text at an offset, in grey until Tab takes it.
|
||||
pub fn set_ghost_text(&mut self, ghost: Option<GhostText>, cx: &mut Context<Self>) {
|
||||
self.marks.ghost = ghost;
|
||||
let text = self.buffer.text();
|
||||
self.marks.ghost =
|
||||
ghost.filter(|ghost| kept(text.is_char_boundary(ghost.offset), "ghost text"));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -412,22 +402,6 @@ impl CodeEditor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replaces ranges of the text at once, as one undo step; read-only text refuses.
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
edits: impl IntoIterator<Item = (Range<usize>, String)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let edits: Vec<(Range<usize>, String)> = edits.into_iter().collect();
|
||||
assert!(
|
||||
edits.iter().all(|(range, _)| range.end <= self.buffer.len()
|
||||
&& self.buffer.text().is_char_boundary(range.start)
|
||||
&& self.buffer.text().is_char_boundary(range.end)),
|
||||
"an edit covers whole characters of the text"
|
||||
);
|
||||
self.apply(edits, false, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_selections(&mut self, selections: Vec<Selection>, cx: &mut Context<Self>) {
|
||||
self.selections = merged(selections);
|
||||
self.unfold_cursors();
|
||||
@@ -446,6 +420,7 @@ impl CodeEditor {
|
||||
self.selections = snapshot.selections;
|
||||
self.marked = None;
|
||||
self.composing = None;
|
||||
anchors::clear(&mut self.marks);
|
||||
cx.emit(EditorEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod stale;
|
||||
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
@@ -8,7 +10,7 @@ use gpui::{
|
||||
use super::{CodeEditor, GhostText, InlayHint, LineNumbers, layout::Row};
|
||||
use crate::theme::Theme;
|
||||
|
||||
fn editor<'a>(
|
||||
pub(super) fn editor<'a>(
|
||||
text: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<CodeEditor>, &'a mut VisualTestContext) {
|
||||
@@ -0,0 +1,252 @@
|
||||
use gpui::{Entity, EntityInputHandler, TestAppContext};
|
||||
|
||||
use super::editor;
|
||||
use crate::{
|
||||
editor::{CodeEditor, GhostText, InlayHint},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
#[gpui::test]
|
||||
fn ranges_from_an_older_text_are_left(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("héllo", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let caret = 1..1;
|
||||
editor.select([caret], cx);
|
||||
editor.select([0..2, 0..99], cx);
|
||||
assert_eq!(
|
||||
editor.primary().head,
|
||||
1,
|
||||
"a stale selection leaves the last"
|
||||
);
|
||||
editor.edit([(0..2, "x".to_string())], cx);
|
||||
editor.edit([(0..3, "x".to_string()), (1..4, "y".to_string())], cx);
|
||||
editor.edit(
|
||||
[(std::ops::Range { start: 3, end: 1 }, "x".to_string())],
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
editor.text(),
|
||||
"héllo",
|
||||
"a stale, crossing or backward batch makes no edit"
|
||||
);
|
||||
editor.edit([(0..1, "j".to_string())], cx);
|
||||
assert_eq!(editor.text(), "jéllo");
|
||||
});
|
||||
}
|
||||
|
||||
/// An output panel whose shown channel left its list.
|
||||
struct Output;
|
||||
|
||||
impl gpui::Render for Output {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
crate::editor::OutputPanel::new("output", ["Build", "Tests"], "Lint", ["one line"])
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_output_channel_gone_from_its_list_draws(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Output);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glob_too_big_for_a_regex_matches_nothing() {
|
||||
let huge = "?".repeat(500_000);
|
||||
assert!(!crate::editor::passes("src/a.rs", &huge, ""));
|
||||
assert!(crate::editor::passes("src/a.rs", "", &huge));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn new_text_drops_what_named_the_old(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("abcd", cx);
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let end = 4..4;
|
||||
editor.select([end], cx);
|
||||
editor.replace_and_mark_text_in_range(None, "ni", None, window, cx);
|
||||
editor.set_inlay_hints(
|
||||
vec![InlayHint {
|
||||
offset: 1,
|
||||
text: ": i32".into(),
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
editor.set_backgrounds(vec![(1..2, gpui::black())], cx);
|
||||
editor.set_text("é", cx);
|
||||
editor.replace_text_in_range(None, "你", window, cx);
|
||||
editor.set_ghost_text(
|
||||
Some(GhostText {
|
||||
offset: 4,
|
||||
text: "late".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
editor.indent(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(editor.read_with(cx, |editor, _| editor.marks.hints.is_empty()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_diagnostic_rides_the_typing_before_it(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("ab", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let note = |range| crate::editor::Diagnostic {
|
||||
range,
|
||||
severity: crate::primitives::Severity::Danger,
|
||||
message: "note".into(),
|
||||
};
|
||||
editor.set_diagnostics(vec![note(1..2)], cx);
|
||||
let start = 0..0;
|
||||
editor.select([start], cx);
|
||||
editor.type_text("é", cx);
|
||||
assert_eq!(editor.marks.diagnostics[0].range, 3..4);
|
||||
let whole = 0..4;
|
||||
editor.select([whole], cx);
|
||||
editor.type_text("x", cx);
|
||||
assert!(
|
||||
editor.marks.diagnostics.is_empty(),
|
||||
"an edit over it takes it"
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A sticky editor in a box short enough to scroll.
|
||||
struct Boxed(Entity<CodeEditor>);
|
||||
|
||||
impl gpui::Render for Boxed {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div()
|
||||
.w(gpui::px(480.0))
|
||||
.h(gpui::px(240.0))
|
||||
.child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_scrolled_sticky_editor_takes_a_shorter_text(cx: &mut TestAppContext) {
|
||||
use gpui::AppContext as _;
|
||||
cx.update(Theme::init);
|
||||
let text = format!("outer\n inner\n{}\nend", vec![" child"; 100].join("\n"));
|
||||
let (view, cx) = cx.add_window_view(move |window, cx| {
|
||||
Boxed(cx.new(|cx| CodeEditor::new(text, window, cx).sticky_scroll()))
|
||||
});
|
||||
let editor = view.read_with(cx, |root, _| root.0.clone());
|
||||
cx.run_until_parked();
|
||||
editor.update(cx, |editor, cx| {
|
||||
let at = editor.buffer.offset(50, 0);
|
||||
let caret = at..at;
|
||||
editor.select([caret], cx);
|
||||
});
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
let offset = editor.read_with(cx, |editor, _| {
|
||||
editor.scroll.0.borrow().base_handle.offset()
|
||||
});
|
||||
assert!(
|
||||
offset.y < gpui::Pixels::ZERO,
|
||||
"the editor scrolled: {offset:?}"
|
||||
);
|
||||
editor.update(cx, |editor, cx| editor.set_text("x", cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_point_on_a_row_the_text_lost_has_no_offset(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("first\nsecond\nthird", cx);
|
||||
cx.run_until_parked();
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let position = gpui::point(
|
||||
editor.metrics.left,
|
||||
editor.metrics.top + editor.metrics.line * 1.5,
|
||||
);
|
||||
assert_eq!(
|
||||
editor.character_index_for_point(position, window, cx),
|
||||
Some(6)
|
||||
);
|
||||
editor.set_text("x", cx);
|
||||
assert_eq!(editor.character_index_for_point(position, window, cx), None);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A press at a window point, sent as the platform would.
|
||||
fn press(position: gpui::Point<gpui::Pixels>) -> gpui::PlatformInput {
|
||||
gpui::PlatformInput::MouseDown(gpui::MouseDownEvent {
|
||||
position,
|
||||
button: gpui::MouseButton::Left,
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_or_drag_on_a_row_the_text_lost_is_dropped(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("first\nsecond\nthird", cx);
|
||||
cx.run_until_parked();
|
||||
let position = editor.read_with(cx, |editor, _| {
|
||||
gpui::point(
|
||||
editor.metrics.left + editor.metrics.advance,
|
||||
editor.metrics.top + editor.metrics.line * 1.5,
|
||||
)
|
||||
});
|
||||
cx.simulate_mouse_down(position, gpui::MouseButton::Left, gpui::Modifiers::none());
|
||||
assert_eq!(editor.read_with(cx, |editor, _| editor.position().0), 2);
|
||||
cx.update(|window, cx| {
|
||||
editor.update(cx, |editor, cx| editor.set_text("x", cx));
|
||||
let held = gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
|
||||
position,
|
||||
pressed_button: Some(gpui::MouseButton::Left),
|
||||
modifiers: gpui::Modifiers::none(),
|
||||
});
|
||||
window.dispatch_event(held, cx);
|
||||
window.dispatch_event(press(position), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(editor.read_with(cx, |editor, _| editor.position().0), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_sticky_press_on_a_line_the_text_lost_is_dropped(cx: &mut TestAppContext) {
|
||||
use gpui::AppContext as _;
|
||||
cx.update(Theme::init);
|
||||
let text = format!("outer\n inner\n{}\nend", vec![" child"; 100].join("\n"));
|
||||
let (view, cx) = cx.add_window_view(move |window, cx| {
|
||||
Boxed(cx.new(|cx| CodeEditor::new(text, window, cx).sticky_scroll()))
|
||||
});
|
||||
let editor = view.read_with(cx, |root, _| root.0.clone());
|
||||
cx.run_until_parked();
|
||||
editor.update(cx, |editor, cx| {
|
||||
let at = editor.buffer.offset(50, 0);
|
||||
let caret = at..at;
|
||||
editor.select([caret], cx);
|
||||
});
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
let position = editor.read_with(cx, |editor, _| {
|
||||
gpui::point(
|
||||
gpui::px(200.0),
|
||||
editor.metrics.top + editor.metrics.line * 1.5,
|
||||
)
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
editor.update(cx, |editor, cx| editor.set_text("x", cx));
|
||||
window.dispatch_event(press(position), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
+19
-11
@@ -85,6 +85,9 @@ impl CodeEditor {
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
jump.update(cx, |editor, cx| {
|
||||
if line >= editor.buffer.lines() {
|
||||
return;
|
||||
}
|
||||
let at = editor.buffer.line_range(line).start + editor.buffer.indent(line);
|
||||
let cursor = at..at;
|
||||
editor.select([cursor], cx);
|
||||
@@ -219,23 +222,23 @@ impl CodeEditor {
|
||||
|
||||
impl Render for CodeEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let sticky = if self.options.sticky {
|
||||
self.sticky(cx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let mono_family = theme.mono_family.clone();
|
||||
let size = theme.text_size(TextSize::Sm);
|
||||
let pixels = size.to_pixels(window.rem_size());
|
||||
let mono = window
|
||||
.text_system()
|
||||
.resolve_font(&font(theme.mono_family.clone()));
|
||||
let advance = window
|
||||
.text_system()
|
||||
.advance(mono, pixels, 'm')
|
||||
.expect("the code font has an m")
|
||||
.width;
|
||||
let advance = match window.text_system().advance(mono, pixels, 'm') {
|
||||
Ok(advance) => advance.width,
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"code editor: no advance for the code font: {error:#}; columns guessed"
|
||||
);
|
||||
pixels * 0.6
|
||||
}
|
||||
};
|
||||
let line = (pixels * LEADING).round();
|
||||
let found = folds(&self.buffer);
|
||||
let hide = hidden(self.buffer.lines(), &found, &self.folded);
|
||||
@@ -260,6 +263,11 @@ impl Render for CodeEditor {
|
||||
};
|
||||
self.metrics.advance = advance;
|
||||
self.metrics.line = line;
|
||||
let sticky = if self.options.sticky {
|
||||
self.sticky(cx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let count = self.frame.rows.len();
|
||||
let list = uniform_list(
|
||||
"editor-rows",
|
||||
@@ -311,7 +319,7 @@ impl Render for CodeEditor {
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.bg(colors.surface)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.font_family(mono_family)
|
||||
.text_size(size)
|
||||
.text_color(colors.fg)
|
||||
.on_mouse_up(
|
||||
|
||||
Reference in new issue
Block a user