From 8ea952c6dc983ebb68d9b6bddbf8ceb77226741f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 01:07:37 -0400 Subject: [PATCH] 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 --- TASKS.md | 16 ++++--- src/editor/cursor.rs | 16 ++++++- src/editor/edit.rs | 57 +++++++++++++++++++++-- src/editor/filters.rs | 10 ++++- src/editor/gutter.rs | 13 ++++-- src/editor/ime.rs | 16 ++++++- src/editor/mod.rs | 2 +- src/editor/problems.rs | 13 +++--- src/editor/search.rs | 42 +++++++++++++++-- src/editor/settings.rs | 20 +++++++++ src/editor/state.rs | 8 +++- src/editor/symbols.rs | 29 +++++++++++- src/editor/tests.rs | 97 +++++++++++++++++++++++++++++++++++++++- src/editor/welcome.rs | 38 +++++++++++----- src/finance/depth.rs | 2 +- src/finance/labels.rs | 13 +++--- src/finance/renko.rs | 4 +- src/finance/stage.rs | 31 ------------- src/finance/tape.rs | 6 ++- src/typography/format.rs | 35 +++++++++++++++ 20 files changed, 383 insertions(+), 85 deletions(-) diff --git a/TASKS.md b/TASKS.md index ff6150b..b30db6b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -64,7 +64,7 @@ Per-component lines live in `tasks/`. Tags there: - [ ] T17b Editor · Intelligence - [ ] T17c Editor · Search - [ ] T17d Editor · Panels -- [ ] T17e Editor · Status items +- [x] T17e Editor · Status items - [ ] T18 Terminal - [ ] T19 Git - [ ] T20 Debug @@ -229,8 +229,12 @@ Per-component lines live in `tasks/`. Tags there: | T16d | 2 | PASS | | | T16e | 1 | FAIL | a smaller loss than expected read as a miss | | T16e | 2 | PASS | | -| T17a | 1 | — | pending review | -| T17b | 1 | — | pending review | -| T17c | 1 | — | pending review | -| T17d | 1 | — | pending review | -| T17e | 1 | — | pending review | +| T17a | 1 | FAIL | hidden numbers crashed the gutter; an edit with nothing to change tripped an assert; a read-only editor moved its caret and undid; a hint past a multi-line ghost fell off its row; the newest cursor lost the lead on merge; a slow composition left its pinyin in undo; gpui tests broke a featureless test build | +| T17a | 2 | — | pending review | +| T17b | 1 | FAIL | two calls of one name shared a tree key; references counted lines, not matches | +| T17b | 2 | — | pending review | +| T17c | 1 | FAIL | a match on trailing spaces ran past its trimmed line; `**/` needed a folder | +| T17c | 2 | — | pending review | +| T17d | 1 | FAIL | a path match marked a project name mid-character; pin and remove also opened the row; number settings showed no places | +| T17d | 2 | — | pending review | +| T17e | 1 | PASS | | diff --git a/src/editor/cursor.rs b/src/editor/cursor.rs index 294cde5..14b7e03 100644 --- a/src/editor/cursor.rs +++ b/src/editor/cursor.rs @@ -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) -> Vec { - 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 = Vec::with_capacity(all.len()); for next in all { @@ -59,6 +62,15 @@ pub(crate) fn merged(mut all: Vec) -> Vec { _ => 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 } diff --git a/src/editor/edit.rs b/src/editor/edit.rs index 6b6c049..505e646 100644 --- a/src/editor/edit.rs +++ b/src/editor/edit.rs @@ -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) { + 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, cx: &mut Context, ) { + 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) { + 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) { + 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) { + 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) { + if !self.editable() { + return; + } let lines: Vec = self .touched() .into_iter() @@ -295,12 +325,18 @@ impl CodeEditor { } pub(crate) fn undo(&mut self, cx: &mut Context) { + 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) { + 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, 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) { + if !self.editable() { + return; + } let copied = self.copied(); let text: Vec = 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) { + 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 { + 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, String)>) -> Vec<(Range, String)> { edits.sort_by_key(|(range, _)| range.start); diff --git a/src/editor/filters.rs b/src/editor/filters.rs index 912a602..25fb3ad 100644 --- a/src/editor/filters.rs +++ b/src/editor/filters.rs @@ -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", diff --git a/src/editor/gutter.rs b/src/editor/gutter.rs index 008b896..070cdd8 100644 --- a/src/editor/gutter.rs +++ b/src/editor/gutter.rs @@ -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() diff --git a/src/editor/ime.rs b/src/editor/ime.rs index a710af9..c7b7fd5 100644 --- a/src/editor/ime.rs +++ b/src/editor/ime.rs @@ -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, cx: &App) -> Option { let metrics = self.metrics; @@ -129,6 +138,7 @@ impl EntityInputHandler for CodeEditor { fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { 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 { diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 19727e4..1fb3c69 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -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; diff --git a/src/editor/problems.rs b/src/editor/problems.rs index 322cc55..b3f6ffc 100644 --- a/src/editor/problems.rs +++ b/src/editor/problems.rs @@ -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), diff --git a/src/editor/search.rs b/src/editor/search.rs index 21a7701..bba6fa9 100644 --- a/src/editor/search.rs +++ b/src/editor/search.rs @@ -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>) { 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>) { (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; type OnPath = Rc; @@ -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; diff --git a/src/editor/settings.rs b/src/editor/settings.rs index d513569..62d160c 100644 --- a/src/editor/settings.rs +++ b/src/editor/settings.rs @@ -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![( diff --git a/src/editor/state.rs b/src/editor/state.rs index 8a729ac..62fce8a 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -107,6 +107,8 @@ pub struct CodeEditor { pub(crate) options: Options, pub(crate) marks: Marks, pub(crate) marked: Option>, + /// The text before an input method's composition began, while it runs. + pub(crate) composing: Option, 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) { 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); diff --git a/src/editor/symbols.rs b/src/editor/symbols.rs index 6bad8d7..4200899 100644 --- a/src/editor/symbols.rs +++ b/src/editor/symbols.rs @@ -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) { - 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![ diff --git a/src/editor/tests.rs b/src/editor/tests.rs index 8295b6e..6728b0e 100644 --- a/src/editor/tests.rs +++ b/src/editor/tests.rs @@ -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 { diff --git a/src/editor/welcome.rs b/src/editor/welcome.rs index 04eb766..fb52554 100644 --- a/src/editor/welcome.rs +++ b/src/editor/welcome.rs @@ -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>)> { + 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 { diff --git a/src/finance/depth.rs b/src/finance/depth.rs index de24d39..0f72451 100644 --- a/src/finance/depth.rs +++ b/src/finance/depth.rs @@ -165,7 +165,7 @@ impl RenderOnce for DepthChart { }) .collect(); let (_, price_ticks) = nice(mid - reach, mid + reach, 5); - let digits = super::stage::decimals( + let digits = crate::typography::format::decimals( price_ticks .get(1) .zip(price_ticks.first()) diff --git a/src/finance/labels.rs b/src/finance/labels.rs index 5bf6270..2171ae5 100644 --- a/src/finance/labels.rs +++ b/src/finance/labels.rs @@ -6,16 +6,15 @@ use gpui::{ }; use jiff::tz::TimeZone; -use super::{ - candles::Candle, - series::Drawn, - stage::{Visible, decimals}, - tools::Drawing, -}; +use super::{candles::Candle, series::Drawn, stage::Visible, tools::Drawing}; use crate::{ charts::{Linear, Rect, tint}, theme::{ActiveTheme, Radius, TextSize}, - typography::{LEADING, format, tabular}, + typography::{ + LEADING, + format::{self, decimals}, + tabular, + }, }; /// Where a market chart's words go: its panes and their scales, what is in view, the candle and row under the pointer, the zone its times read in, its title, and a compared symbol's name. diff --git a/src/finance/renko.rs b/src/finance/renko.rs index c671cba..d46e736 100644 --- a/src/finance/renko.rs +++ b/src/finance/renko.rs @@ -10,7 +10,7 @@ use jiff::tz::TimeZone; use super::{ candles::{Candle, point_and_figure, renko}, quotes::moves, - stage::{decimals, fit}, + stage::fit, }; use crate::{ charts::{ @@ -18,7 +18,7 @@ use crate::{ }, theme::{ActiveTheme, TextSize}, typography::{ - format::{self, system_zone}, + format::{self, decimals, system_zone}, tabular, }, }; diff --git a/src/finance/stage.rs b/src/finance/stage.rs index 6e1eb5d..94f300c 100644 --- a/src/finance/stage.rs +++ b/src/finance/stage.rs @@ -104,21 +104,6 @@ pub(crate) fn fit(low: f64, high: f64) -> ((f64, f64), Vec) { ((low, high), inside) } -/// Digits kept past a step's first; float noise sits near the sixteenth. -const DIGITS: usize = 6; - -/// Places after the point that show every multiple of `step` exactly: none for whole steps, four for 0.0025. -pub(crate) fn decimals(step: f64) -> usize { - assert!( - step.is_finite() && step > 0.0, - "a step is positive, not {step}" - ); - let leading = (-step.log10().floor()).max(0.0) as usize; - let text = format!("{step:.*}", leading + DIGITS); - let fraction = text.split_once('.').map_or("", |(_, fraction)| fraction); - fraction.trim_end_matches('0').len() -} - #[cfg(test)] mod tests { use super::*; @@ -196,21 +181,5 @@ mod tests { let ((low, high), ticks) = fit(100.0, 110.0); assert!(low < 100.0 && high > 110.0); assert!(ticks.iter().all(|tick| (low..=high).contains(tick)) && ticks.len() >= 3); - assert_eq!( - ( - decimals(10.0), - decimals(0.5), - decimals(0.05), - decimals(0.001) - ), - (0, 1, 2, 3) - ); - assert_eq!(decimals(0.0025), 4, "a quarter of a cent needs four places"); - assert_eq!( - decimals(110.2 - 110.0), - 1, - "float noise from a difference drops away" - ); - assert_eq!(decimals(1e-11), 11, "a tiny step keeps its places"); } } diff --git a/src/finance/tape.rs b/src/finance/tape.rs index 5963a39..099c9f8 100644 --- a/src/finance/tape.rs +++ b/src/finance/tape.rs @@ -9,13 +9,15 @@ use jiff::tz::TimeZone; use super::{ book::{Side, Trade, cell, heading, level}, quotes::{moves, price}, - stage::decimals, }; use crate::{ charts::compact, motion::Flash, theme::{ActiveTheme, Density, TextSize}, - typography::{format::system_zone, tabular}, + typography::{ + format::{decimals, system_zone}, + tabular, + }, }; /// Trades as they print, newest on top: the time, price and size tinted by the side that took them; trades of at least `large` stand bold, and the newest flashes in. diff --git a/src/typography/format.rs b/src/typography/format.rs index b357dd9..03cce25 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -136,6 +136,21 @@ pub fn duration(seconds: u64, style: DurationStyle) -> String { } } +/// Digits kept past a step's first; float noise sits near the sixteenth. +const DIGITS: usize = 6; + +/// Places after the point that show every multiple of `step` exactly: none for whole steps, four for 0.0025. +pub(crate) fn decimals(step: f64) -> usize { + assert!( + step.is_finite() && step > 0.0, + "a step is positive, not {step}" + ); + let leading = (-step.log10().floor()).max(0.0) as usize; + let text = format!("{step:.*}", leading + DIGITS); + let fraction = text.split_once('.').map_or("", |(_, fraction)| fraction); + fraction.trim_end_matches('0').len() +} + /// "1 file", "12,345 files". pub fn plural(count: u64, one: &str, other: &str) -> String { let word = if count == 1 { one } else { other }; @@ -189,6 +204,26 @@ pub fn datetime(at: Timestamp, zone: &TimeZone, pattern: &str) -> Result