T17 review fixes: hidden numbers draw, empty edits and read-only commands change nothing, hints stay in a ghost-cut row, the newest cursor leads, a composition undoes in one step, call keys carry their place, references count matches, trailing-space hits and **/ globs, name-only switcher marks, pin and remove stay on their button, settings show their places; decimals moves to typography::format
This commit is contained in:
1 parent
5545ce6458
commit
8ea952c6dc
20 files changed
+383
-85
No files matched your search
+14
-2
@@ -37,9 +37,12 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted by where they start, with overlapping selections joined into one.
|
||||
/// Sorted by where they start, overlapping selections joined into one; the last given, the primary, stays last.
|
||||
pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
|
||||
assert!(!all.is_empty(), "an editor keeps at least one cursor");
|
||||
let primary = all
|
||||
.last()
|
||||
.expect("an editor keeps at least one cursor")
|
||||
.range();
|
||||
all.sort_by_key(|selection| selection.range().start);
|
||||
let mut out: Vec<Selection> = Vec::with_capacity(all.len());
|
||||
for next in all {
|
||||
@@ -59,6 +62,15 @@ pub(crate) fn merged(mut all: Vec<Selection>) -> Vec<Selection> {
|
||||
_ => out.push(next),
|
||||
}
|
||||
}
|
||||
let lead = out
|
||||
.iter()
|
||||
.position(|selection| {
|
||||
let range = selection.range();
|
||||
range.start <= primary.start && primary.end <= range.end
|
||||
})
|
||||
.expect("a merge keeps every cursor inside one");
|
||||
let lead = out.remove(lead);
|
||||
out.push(lead);
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
+54
-3
@@ -33,8 +33,19 @@ impl CodeEditor {
|
||||
log::debug!("code editor: read only, edit dropped");
|
||||
return;
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let before = self.snapshot();
|
||||
let carets = replace_each(&mut self.buffer, joined(edits));
|
||||
let primary = self.primary().range().start;
|
||||
let edits = joined(edits);
|
||||
let lead = edits
|
||||
.iter()
|
||||
.rposition(|(range, _)| range.start <= primary)
|
||||
.unwrap_or(0);
|
||||
let mut carets = replace_each(&mut self.buffer, edits);
|
||||
let lead = carets.remove(lead);
|
||||
carets.push(lead);
|
||||
self.selections = merged(carets);
|
||||
self.marked = None;
|
||||
self.commit(before, typing, cx);
|
||||
@@ -43,6 +54,9 @@ impl CodeEditor {
|
||||
|
||||
/// Types text at each cursor; an opener brings its closer, and a closer already there is stepped over.
|
||||
pub(crate) fn type_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let mut chars = text.chars();
|
||||
let (Some(ch), None) = (chars.next(), chars.next()) else {
|
||||
return self.insert(text, true, cx);
|
||||
@@ -87,6 +101,9 @@ impl CodeEditor {
|
||||
reach: fn(&Buffer, usize) -> Range<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let edits = self
|
||||
.selections
|
||||
.iter()
|
||||
@@ -104,6 +121,9 @@ impl CodeEditor {
|
||||
|
||||
/// A new line at each cursor, indented like its own; one step deeper after an opener, with the closer on a line of its own.
|
||||
pub(crate) fn newline(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let tab = self.options.tab;
|
||||
let edits = self
|
||||
.selections
|
||||
@@ -166,12 +186,16 @@ impl CodeEditor {
|
||||
self.buffer.line_of(range.start)..=self.buffer.line_of(last)
|
||||
})
|
||||
.collect();
|
||||
lines.sort_unstable();
|
||||
lines.dedup();
|
||||
lines
|
||||
}
|
||||
|
||||
/// Tab: takes ghost text when there is some, indents touched lines when a selection spans lines, or fills to the next tab stop.
|
||||
pub(crate) fn indent(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
if let Some(ghost) = self.marks.ghost.take() {
|
||||
log::info!("code editor: ghost text taken");
|
||||
self.set_selections(vec![Selection::caret(ghost.offset)], cx);
|
||||
@@ -208,6 +232,9 @@ impl CodeEditor {
|
||||
|
||||
/// Shift-Tab: takes one tab stop of indent off each touched line.
|
||||
pub(crate) fn outdent(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let tab = self.options.tab;
|
||||
let kept = self.selections.clone();
|
||||
let edits = self
|
||||
@@ -260,6 +287,9 @@ impl CodeEditor {
|
||||
|
||||
/// Comments the touched lines with `//`, or takes the comment off when every one has it.
|
||||
pub(crate) fn toggle_comment(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<usize> = self
|
||||
.touched()
|
||||
.into_iter()
|
||||
@@ -295,12 +325,18 @@ impl CodeEditor {
|
||||
}
|
||||
|
||||
pub(crate) fn undo(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redo(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
}
|
||||
@@ -308,7 +344,7 @@ impl CodeEditor {
|
||||
|
||||
/// Each selection's text, a line apiece; the whole line for a bare caret.
|
||||
fn copied(&self) -> Vec<(Range<usize>, String)> {
|
||||
self.selections
|
||||
self.in_order()
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = if selection.is_empty() {
|
||||
@@ -329,6 +365,9 @@ impl CodeEditor {
|
||||
}
|
||||
|
||||
pub(crate) fn cut(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let copied = self.copied();
|
||||
let text: Vec<String> = copied.iter().map(|(_, text)| text.clone()).collect();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.join("\n")));
|
||||
@@ -344,12 +383,15 @@ impl CodeEditor {
|
||||
|
||||
/// Pastes one line at each cursor when the counts match, else the whole text at every one.
|
||||
pub(crate) fn paste(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.editable() {
|
||||
return;
|
||||
}
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let edits = if lines.len() == self.selections.len() && lines.len() > 1 {
|
||||
self.selections
|
||||
self.in_order()
|
||||
.iter()
|
||||
.zip(lines)
|
||||
.map(|(selection, line)| (selection.range(), line.to_string()))
|
||||
@@ -364,6 +406,15 @@ impl CodeEditor {
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// The selections from the top of the text down; the primary one is otherwise last.
|
||||
fn in_order(&self) -> Vec<Selection> {
|
||||
let mut all = self.selections.clone();
|
||||
all.sort_by_key(|selection| selection.range().start);
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits sorted, with overlapping ranges joined; the first text of a joined run is kept.
|
||||
fn joined(mut edits: Vec<(Range<usize>, String)>) -> Vec<(Range<usize>, String)> {
|
||||
edits.sort_by_key(|(range, _)| range.start);
|
||||
|
||||
@@ -22,7 +22,11 @@ pub fn passes(path: &str, include: &str, exclude: &str) -> bool {
|
||||
match ch {
|
||||
'*' if chars.peek() == Some(&'*') => {
|
||||
chars.next();
|
||||
pattern.push_str(".*");
|
||||
if chars.next_if_eq(&'/').is_some() {
|
||||
pattern.push_str("(?:.*/)?");
|
||||
} else {
|
||||
pattern.push_str(".*");
|
||||
}
|
||||
}
|
||||
'*' => pattern.push_str("[^/]*"),
|
||||
'?' => pattern.push_str("[^/]"),
|
||||
@@ -129,6 +133,10 @@ mod tests {
|
||||
#[test]
|
||||
fn globs_take_includes_then_drop_excludes() {
|
||||
assert!(passes("src/editor/view.rs", "src/**/*.rs", ""));
|
||||
assert!(
|
||||
passes("src/main.rs", "src/**/*.rs", ""),
|
||||
"** takes no folders too"
|
||||
);
|
||||
assert!(!passes("examples/main.rs", "src/**", ""));
|
||||
assert!(!passes(
|
||||
"src/editor/tests.rs",
|
||||
|
||||
+10
-3
@@ -23,10 +23,17 @@ pub(crate) enum Cell {
|
||||
impl CodeEditor {
|
||||
/// Columns the gutter spans: a slot for a breakpoint or a problem, the number, a fold chevron and the git bar.
|
||||
pub(crate) fn gutter_columns(&self) -> usize {
|
||||
let digits = self.buffer.lines().to_string().len().max(2);
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => 5,
|
||||
_ => digits + 6,
|
||||
_ => self.digits() + 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns the numbers take: none when hidden.
|
||||
fn digits(&self) -> usize {
|
||||
match self.options.numbers {
|
||||
LineNumbers::Hidden => 0,
|
||||
_ => self.buffer.lines().to_string().len().max(2),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +90,7 @@ impl CodeEditor {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let advance = self.frame.advance;
|
||||
let digits = self.gutter_columns() - 6;
|
||||
let digits = self.digits();
|
||||
let frame = || {
|
||||
div()
|
||||
.flex_none()
|
||||
|
||||
+15
-1
@@ -19,7 +19,7 @@ impl CodeEditor {
|
||||
/// Notes set into a line: inlay hints, and the ghost text's first line.
|
||||
pub(crate) fn notes(&self, line: usize, cx: &App) -> Vec<(usize, String, HighlightStyle)> {
|
||||
let colors = &cx.theme().colors;
|
||||
let range = self.buffer.line_range(line);
|
||||
let range = self.row_range(line);
|
||||
let hint = HighlightStyle {
|
||||
color: Some(colors.fg_subtle),
|
||||
background_color: Some(colors.hover),
|
||||
@@ -61,6 +61,15 @@ impl CodeEditor {
|
||||
&self.buffer.text()[self.row_range(line)]
|
||||
}
|
||||
|
||||
/// Ends a composition: all of it, from its first mark, is one undo step.
|
||||
fn end_composition(&mut self) {
|
||||
if let Some(start) = self.composing.take()
|
||||
&& start.text != self.buffer.text()
|
||||
{
|
||||
self.history.record(start, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset under a window point, when a row of code is there.
|
||||
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> {
|
||||
let metrics = self.metrics;
|
||||
@@ -129,6 +138,7 @@ impl EntityInputHandler for CodeEditor {
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.marked = None;
|
||||
self.end_composition();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -149,6 +159,7 @@ impl EntityInputHandler for CodeEditor {
|
||||
}
|
||||
None => self.type_text(text, cx),
|
||||
}
|
||||
self.end_composition();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
@@ -166,6 +177,9 @@ impl EntityInputHandler for CodeEditor {
|
||||
.map(|range| self.byte_range(&range))
|
||||
.or(self.marked.clone())
|
||||
.unwrap_or(self.primary().range());
|
||||
if self.composing.is_none() {
|
||||
self.composing = Some(self.snapshot());
|
||||
}
|
||||
self.apply(vec![(range.clone(), text.to_string())], true, cx);
|
||||
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
|
||||
let caret = match selected_utf16 {
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ mod status;
|
||||
mod symbols;
|
||||
mod syntax;
|
||||
mod tasks;
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod view;
|
||||
mod welcome;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// A problem the tools found: where, how bad, what, and which tool said so.
|
||||
@@ -116,14 +116,13 @@ impl RenderOnce for ProblemsPanel {
|
||||
.count()
|
||||
};
|
||||
let filters = [
|
||||
(Severity::Danger, "errors", "Error"),
|
||||
(Severity::Warning, "warnings", "Warning"),
|
||||
(Severity::Info, "notes", "Note"),
|
||||
(Severity::Danger, "errors", ("Error", "Errors")),
|
||||
(Severity::Warning, "warnings", ("Warning", "Warnings")),
|
||||
(Severity::Info, "notes", ("Note", "Notes")),
|
||||
]
|
||||
.map(|(severity, key, words)| {
|
||||
.map(|(severity, key, (one, other))| {
|
||||
let (shown, on_shown) = (self.shown.clone(), self.on_shown.clone());
|
||||
let found = count(severity);
|
||||
let label = format!("{found} {words}{}", if found == 1 { "" } else { "s" });
|
||||
let label = format::plural(count(severity) as u64, one, other);
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), key),
|
||||
ToggleItem::new(key).icon(severity.icon()).label(label),
|
||||
|
||||
+39
-3
@@ -9,6 +9,7 @@ use gpui::{
|
||||
use crate::{
|
||||
primitives::{Disclosure, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
/// A line that matched: its number, its text, and where in it the matches lie.
|
||||
@@ -33,7 +34,8 @@ const LEAD: usize = 24;
|
||||
fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
|
||||
let first = hit.ranges.first().map_or(0, |range| range.start);
|
||||
let before = hit.text[..first].chars().count();
|
||||
let text = hit.text.trim_end();
|
||||
let last = hit.ranges.iter().map(|range| range.end).max().unwrap_or(0);
|
||||
let text = &hit.text[..hit.text.trim_end().len().max(last)];
|
||||
if before <= LEAD {
|
||||
let start = text.len() - text.trim_start().len();
|
||||
let start = start.min(first);
|
||||
@@ -59,6 +61,15 @@ fn trimmed(hit: &Hit) -> (String, Vec<Range<usize>>) {
|
||||
(shown, ranges)
|
||||
}
|
||||
|
||||
/// How many places the files name the symbol: every match, two on one line counting twice.
|
||||
fn references(files: &[FileHits]) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.flat_map(|file| &file.hits)
|
||||
.map(|hit| hit.ranges.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(&SharedString, usize, &mut Window, &mut App)>;
|
||||
type OnPath = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
@@ -397,7 +408,7 @@ impl RenderOnce for ReferencesPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count: usize = self.files.iter().map(|file| file.hits.len()).sum();
|
||||
let count = references(&self.files);
|
||||
let rows = results(&self.id, &self.files, &[], None, self.on_open, None, cx);
|
||||
div()
|
||||
.flex()
|
||||
@@ -420,7 +431,7 @@ impl RenderOnce for ReferencesPanel {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(format!("{count} references")),
|
||||
.child(format::plural(count as u64, "reference", "references")),
|
||||
),
|
||||
)
|
||||
.children(rows)
|
||||
@@ -431,6 +442,31 @@ impl RenderOnce for ReferencesPanel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_match_on_trailing_spaces_keeps_them() {
|
||||
let spaces = 6..8;
|
||||
let hit = Hit {
|
||||
line: 0,
|
||||
text: "let a; ".into(),
|
||||
ranges: vec![spaces],
|
||||
};
|
||||
let (text, ranges) = trimmed(&hit);
|
||||
assert_eq!(&text[ranges[0].clone()], " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_count_every_match_on_a_line() {
|
||||
let both = FileHits {
|
||||
path: "a.rs".into(),
|
||||
hits: vec![Hit {
|
||||
line: 0,
|
||||
text: "f(f(1))".into(),
|
||||
ranges: vec![0..1, 2..3],
|
||||
}],
|
||||
};
|
||||
assert_eq!(references(&[both]), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lines_start_near_their_first_match() {
|
||||
let needle = 40..46;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
forms::{Choice, JsonInput, NumberInput, Select, Switch, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
typography::format::decimals,
|
||||
};
|
||||
|
||||
/// A setting's value and the form it takes.
|
||||
@@ -241,6 +242,7 @@ impl RenderOnce for SettingsEditor {
|
||||
NumberInput::new(id.clone(), *value)
|
||||
.range(min, max)
|
||||
.step(step)
|
||||
.precision(places(*value, step))
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, window, cx| {
|
||||
tell(
|
||||
@@ -371,10 +373,28 @@ impl RenderOnce for SettingsEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Places a number setting shows: enough for its step and its value.
|
||||
fn places(value: f64, step: f64) -> usize {
|
||||
let own = if value == 0.0 {
|
||||
0
|
||||
} else {
|
||||
decimals(value.abs())
|
||||
};
|
||||
decimals(step).max(own)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numbers_show_the_places_their_value_and_step_need() {
|
||||
assert_eq!(places(0.5, 0.1), 1);
|
||||
assert_eq!(places(14.0, 1.0), 0);
|
||||
assert_eq!(places(0.25, 0.05), 2);
|
||||
assert_eq!(places(0.0, 0.5), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_holds_only_what_changed() {
|
||||
let groups = vec![(
|
||||
|
||||
+7
-1
@@ -107,6 +107,8 @@ pub struct CodeEditor {
|
||||
pub(crate) options: Options,
|
||||
pub(crate) marks: Marks,
|
||||
pub(crate) marked: Option<Range<usize>>,
|
||||
/// The text before an input method's composition began, while it runs.
|
||||
pub(crate) composing: Option<Snapshot>,
|
||||
pub(crate) metrics: Metrics,
|
||||
pub(crate) dragging: bool,
|
||||
pub(crate) caret_on: bool,
|
||||
@@ -172,6 +174,7 @@ impl CodeEditor {
|
||||
},
|
||||
marks: Marks::default(),
|
||||
marked: None,
|
||||
composing: None,
|
||||
metrics: Metrics::default(),
|
||||
dragging: false,
|
||||
caret_on: true,
|
||||
@@ -435,6 +438,7 @@ impl CodeEditor {
|
||||
self.buffer = Buffer::new(snapshot.text);
|
||||
self.selections = snapshot.selections;
|
||||
self.marked = None;
|
||||
self.composing = None;
|
||||
cx.emit(EditorEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
@@ -442,7 +446,9 @@ impl CodeEditor {
|
||||
/// Makes `before` one undo step when the text moved on from it.
|
||||
pub(crate) fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
|
||||
if before.text != self.buffer.text() {
|
||||
self.history.record(before, typing);
|
||||
if self.composing.is_none() {
|
||||
self.history.record(before, typing);
|
||||
}
|
||||
self.marks.ghost = None;
|
||||
self.folded.retain(|line| *line < self.buffer.lines());
|
||||
cx.emit(EditorEvent::Changed);
|
||||
|
||||
+27
-2
@@ -362,8 +362,13 @@ impl CallHierarchy {
|
||||
}
|
||||
}
|
||||
|
||||
/// A call's node key: its callers' keys, its name and its place, so two calls of one name stay apart.
|
||||
fn call_key(call: &Call, path: &str) -> String {
|
||||
format!("{path}/{}@{}", call.name, call.place)
|
||||
}
|
||||
|
||||
fn call_node(call: &Call, path: &str) -> TreeNode {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
let key = call_key(call, path);
|
||||
let node = TreeNode::new(key.clone(), call.name.clone())
|
||||
.icon(call.kind.icon())
|
||||
.note(call.place.clone());
|
||||
@@ -374,7 +379,7 @@ fn call_node(call: &Call, path: &str) -> TreeNode {
|
||||
}
|
||||
|
||||
fn keys(call: &Call, path: &str, out: &mut Vec<SharedString>) {
|
||||
let key = format!("{path}/{}", call.name);
|
||||
let key = call_key(call, path);
|
||||
for inner in &call.calls {
|
||||
keys(inner, &key, out);
|
||||
}
|
||||
@@ -420,6 +425,26 @@ impl RenderOnce for CallHierarchy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calls_of_one_name_from_two_places_keep_their_own_keys() {
|
||||
let call = |name: &str, place: &str| Call {
|
||||
name: name.to_string().into(),
|
||||
kind: SymbolKind::Function,
|
||||
place: place.to_string().into(),
|
||||
calls: Vec::new(),
|
||||
};
|
||||
let root = Call {
|
||||
calls: vec![call("draw", "a.rs:1"), call("draw", "b.rs:9")],
|
||||
..call("main", "main.rs:1")
|
||||
};
|
||||
let mut all = Vec::new();
|
||||
keys(&root, "", &mut all);
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_innermost_symbol_above_a_line_holds_it() {
|
||||
let symbols = vec![
|
||||
|
||||
+95
-2
@@ -1,6 +1,8 @@
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{CodeEditor, GhostText, layout::Row};
|
||||
use gpui::{Entity, EntityInputHandler, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{CodeEditor, GhostText, InlayHint, LineNumbers, layout::Row};
|
||||
use crate::theme::Theme;
|
||||
|
||||
fn editor<'a>(
|
||||
@@ -101,6 +103,97 @@ fn a_selection_inside_a_fold_opens_it(cx: &mut TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn hidden_numbers_draw(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("a\nb", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_line_numbers(LineNumbers::Hidden, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(editor.read_with(cx, |editor, _| editor.gutter_columns()), 5);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn edits_with_nothing_to_change_leave_the_text(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("a\n\nb", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.outdent(cx);
|
||||
let blank = 2..2;
|
||||
editor.select([blank], cx);
|
||||
editor.toggle_comment(cx);
|
||||
assert_eq!(editor.text(), "a\n\nb");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_read_only_editor_ignores_typing_and_undo(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("x", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let end = 1..1;
|
||||
editor.select([end], cx);
|
||||
editor.type_text("y", cx);
|
||||
editor.set_read_only(true, cx);
|
||||
editor.type_text("(", cx);
|
||||
assert_eq!(editor.primary().head, 2, "the caret stays");
|
||||
editor.undo(cx);
|
||||
assert_eq!(editor.text(), "xy");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hint_past_a_multi_line_ghost_stays_in_its_row(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("call(x)\nnext", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_inlay_hints(
|
||||
vec![InlayHint {
|
||||
offset: 6,
|
||||
text: ": i32".into(),
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
editor.set_ghost_text(
|
||||
Some(GhostText {
|
||||
offset: 5,
|
||||
text: "a,\n".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
let row = editor.row_text(0).len();
|
||||
assert!(editor.notes(0, cx).iter().all(|(at, ..)| *at <= row));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn cursors_added_above_keep_climbing(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("a\nb\nc\nd", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let last = 6..6;
|
||||
editor.select([last], cx);
|
||||
editor.add_cursor(false, cx);
|
||||
editor.add_cursor(false, cx);
|
||||
assert_eq!(editor.selections().len(), 3);
|
||||
assert_eq!(editor.position().0, 2, "the newest cursor leads");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_slow_composition_undoes_in_one_step(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("", cx);
|
||||
for pinyin in ["n", "ni"] {
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.replace_and_mark_text_in_range(None, pinyin, None, window, cx)
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(950));
|
||||
}
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.replace_text_in_range(None, "你", window, cx);
|
||||
assert_eq!(editor.text(), "你");
|
||||
editor.undo(cx);
|
||||
assert_eq!(editor.text(), "", "the pinyin was never a step");
|
||||
});
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Whether line `line` has a row now.
|
||||
fn frame_shows(&self, line: usize) -> bool {
|
||||
|
||||
+28
-10
@@ -90,7 +90,10 @@ impl RenderOnce for RecentProjects {
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.when_some(action, move |button, action| {
|
||||
button.on_click(move |_, window, cx| action(ix, window, cx))
|
||||
button.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
action(ix, window, cx)
|
||||
})
|
||||
})
|
||||
};
|
||||
div()
|
||||
@@ -215,16 +218,10 @@ impl RenderOnce for ProjectSwitcher {
|
||||
let mut found: Vec<(i32, Row)> = ordered(&self.projects)
|
||||
.into_iter()
|
||||
.filter_map(|(ix, project)| {
|
||||
let fit = if query.is_empty() {
|
||||
None
|
||||
let (score, hits) = if query.is_empty() {
|
||||
(0, Vec::new())
|
||||
} else {
|
||||
Some(fuzzy(&query, &project.name).or_else(|| fuzzy(&query, &project.path))?)
|
||||
};
|
||||
let (score, hits) = fit.map_or((0, Vec::new()), |fit| (fit.score, fit.hits));
|
||||
let hits = if hits.iter().all(|hit| hit.end <= project.name.len()) {
|
||||
hits
|
||||
} else {
|
||||
Vec::new()
|
||||
fit(&query, project)?
|
||||
};
|
||||
Some((
|
||||
score,
|
||||
@@ -282,6 +279,14 @@ impl RenderOnce for ProjectSwitcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// How well `query` fits a project, by name or else by path, and the name's letters it marks; a path match marks none.
|
||||
fn fit(query: &str, project: &Project) -> Option<(i32, Vec<std::ops::Range<usize>>)> {
|
||||
if let Some(fit) = fuzzy(query, &project.name) {
|
||||
return Some((fit.score, fit.hits));
|
||||
}
|
||||
fuzzy(query, &project.path).map(|fit| (fit.score, Vec::new()))
|
||||
}
|
||||
|
||||
/// A way to start from the welcome page: its icon, words and keys.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StartAction {
|
||||
@@ -454,6 +459,19 @@ impl RenderOnce for WelcomePage {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_name_match_marks_the_name() {
|
||||
let project = Project {
|
||||
name: "中文".into(),
|
||||
path: "foo".into(),
|
||||
branch: None,
|
||||
opened: "now".into(),
|
||||
pinned: false,
|
||||
};
|
||||
assert_eq!(fit("oo", &project).map(|(_, hits)| hits), Some(Vec::new()));
|
||||
assert!(fit("zz", &project).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_projects_lead_and_keep_their_places() {
|
||||
let project = |name: &str, pinned: bool| Project {
|
||||
|
||||
Reference in new issue
Block a user