Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
use super::EditorElement;
const FLOAT_TOLERANCE: f32 = 1e-4;
#[test]
fn scroll_position_y_fract_is_continuous_at_first_visible_row_boundary_without_top_section() {
let line_height = 20.0;
let fract_before_boundary = EditorElement::scroll_position_y_fract(0.99, line_height, 0.0);
assert!((fract_before_boundary - 19.8).abs() < FLOAT_TOLERANCE);
let fract_at_boundary = EditorElement::scroll_position_y_fract(1.0, line_height, 0.0);
assert!((fract_at_boundary - 0.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn scroll_position_y_fract_is_continuous_at_first_visible_row_boundary_with_top_section() {
let line_height = 20.0;
let top_section_height_px = 10.0;
let fract_before_boundary =
EditorElement::scroll_position_y_fract(1.49, line_height, top_section_height_px);
assert!((fract_before_boundary - 29.8).abs() < FLOAT_TOLERANCE);
let fract_at_boundary =
EditorElement::scroll_position_y_fract(1.5, line_height, top_section_height_px);
assert!((fract_at_boundary - 0.0).abs() < FLOAT_TOLERANCE);
}
#[test]
fn scroll_position_y_fract_tracks_fractional_offset_after_boundary() {
let line_height = 20.0;
let top_section_height_px = 10.0;
let fract = EditorElement::scroll_position_y_fract(1.75, line_height, top_section_height_px);
assert!((fract - 5.0).abs() < FLOAT_TOLERANCE);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -0,0 +1,42 @@
/// Returns `true` if `bytes` is a PNG whose `tEXt` metadata contains `Software: Figma`.
///
/// Figma exports PNGs with a `tEXt` chunk where the keyword is `Software` and the value
/// is `Figma`. We scan the raw chunk stream for this marker without pulling in an image
/// parsing library, keeping the check lightweight.
const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
pub fn is_figma_png(bytes: &[u8]) -> bool {
if !bytes.starts_with(PNG_SIGNATURE) {
return false;
}
let mut offset = 8usize;
while offset + 12 <= bytes.len() {
let length = u32::from_be_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
]) as usize;
let type_start = offset + 4;
let data_start = type_start + 4;
let Some(data_end) = data_start.checked_add(length) else {
break;
};
if data_end + 4 > bytes.len() {
break;
}
// tEXt chunk: data is `keyword\0text`
if &bytes[type_start..data_start] == b"tEXt"
&& bytes[data_start..data_end].starts_with(b"Software\x00Figma")
{
return true;
}
// Advance past: length(4) + type(4) + data(length) + CRC(4)
offset = data_end + 4;
}
false
}
#[cfg(test)]
#[path = "is_figma_png_tests.rs"]
mod tests;
@@ -0,0 +1,85 @@
use super::is_figma_png;
fn build_png_with_text_chunk(keyword: &[u8], text: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
let data: Vec<u8> = keyword.iter().chain(b"\x00").chain(text).copied().collect();
let length = data.len() as u32;
bytes.extend_from_slice(&length.to_be_bytes());
bytes.extend_from_slice(b"tEXt");
bytes.extend_from_slice(&data);
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
bytes
}
#[test]
fn returns_true_for_figma_export_png() {
let bytes = include_bytes!("figma-export.png");
assert!(is_figma_png(bytes));
}
#[test]
fn returns_false_for_non_figma_png() {
let bytes = include_bytes!("non-figma-export.png");
assert!(!is_figma_png(bytes));
}
#[test]
fn returns_false_for_empty_bytes() {
assert!(!is_figma_png(&[]));
}
#[test]
fn returns_false_for_invalid_png_signature() {
let mut bytes = b"\x00PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(&[0u8; 12]);
assert!(!is_figma_png(&bytes));
}
#[test]
fn returns_true_for_crafted_png_with_software_figma() {
let bytes = build_png_with_text_chunk(b"Software", b"Figma");
assert!(is_figma_png(&bytes));
}
#[test]
fn returns_false_when_text_chunk_keyword_is_not_software() {
let bytes = build_png_with_text_chunk(b"Author", b"Figma");
assert!(!is_figma_png(&bytes));
}
#[test]
fn returns_false_when_software_value_is_not_figma() {
let bytes = build_png_with_text_chunk(b"Software", b"Sketch");
assert!(!is_figma_png(&bytes));
}
#[test]
fn returns_true_when_figma_text_chunk_follows_another_chunk() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
// A preceding chunk (e.g. tIME)
let preceding = b"dummy data";
bytes.extend_from_slice(&(preceding.len() as u32).to_be_bytes());
bytes.extend_from_slice(b"tIME");
bytes.extend_from_slice(preceding);
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
// tEXt chunk with Software: Figma
let text_data = b"Software\x00Figma";
bytes.extend_from_slice(&(text_data.len() as u32).to_be_bytes());
bytes.extend_from_slice(b"tEXt");
bytes.extend_from_slice(text_data);
bytes.extend_from_slice(&[0u8; 4]); // fake CRC
assert!(is_figma_png(&bytes));
}
#[test]
fn returns_false_for_truncated_chunk_data() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
// Declare length as 100 but provide fewer bytes
bytes.extend_from_slice(&100u32.to_be_bytes());
bytes.extend_from_slice(b"tEXt");
bytes.extend_from_slice(b"Software\x00Figma"); // only 15 bytes, not 100
assert!(!is_figma_png(&bytes));
}
+3
View File
@@ -0,0 +1,3 @@
mod is_figma_png;
pub use is_figma_png::is_figma_png;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+192
View File
@@ -0,0 +1,192 @@
use vim::vim::VimMode;
use warp_core::features::FeatureFlag;
use warpui::{keymap::Keystroke, platform::WindowStyle, App};
use crate::editor::{DisplayPoint, EditorOptions, EditorView};
use super::initialize_app;
#[test]
fn test_set_marked_text() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
app.add_window(WindowStyle::NotStealFocus, |ctx| {
let mut editor = EditorView::new_with_base_text("", Default::default(), ctx);
// Simulate typing in "nihao" into the IME and then selecting "你好" as the candidate.
editor.set_marked_text("nihao", &(5..5), ctx);
assert_eq!(editor.selected_text(ctx), "nihao");
editor.ime_commit("你好", ctx);
assert_eq!(editor.buffer_text(ctx), "你好");
editor.user_insert(", I am Teddy ", ctx);
assert_eq!(editor.buffer_text(ctx), "你好, I am Teddy ".to_owned());
// Simulate typing in "xiong" into the IME and selecting "熊" as the candidate.
editor.set_marked_text("xiong", &(5..5), ctx);
assert_eq!(editor.selected_text(ctx), "xiong");
editor.ime_commit("", ctx);
assert_eq!(editor.buffer_text(ctx), "你好, I am Teddy 熊".to_owned());
editor
});
});
}
#[test]
fn test_set_marked_text_multiple_empty_selections() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
app.add_window(WindowStyle::NotStealFocus, |ctx| {
let mut editor = EditorView::new_with_base_text(" is ", Default::default(), ctx);
// Set two cursors: one at the beginning and one at the end.
editor
.select_ranges(
vec![
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
DisplayPoint::new(0, 4)..DisplayPoint::new(0, 4),
],
ctx,
)
.unwrap();
assert_eq!(editor.selections(ctx).len(), 2);
// Simulate typing in "pyaar" into the IME and then selecting "प्यार" as the candidate.
editor.set_marked_text("pyaar", &(5..5), ctx);
for selected_text in editor.selected_text_strings(ctx).iter() {
assert_eq!(selected_text, "pyaar");
}
editor.ime_commit("प्यार", ctx);
assert_eq!(editor.buffer_text(ctx), "प्यार is प्यार".to_owned());
editor
});
});
}
#[test]
fn test_set_marked_text_multiple_nonempty_selections() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
app.add_window(WindowStyle::NotStealFocus, |ctx| {
let mut editor =
EditorView::new_with_base_text("love is love", Default::default(), ctx);
// Select both instances of "love" in the buffer text.
editor
.select_ranges(
vec![
DisplayPoint::new(0, 0)..DisplayPoint::new(0, 4),
DisplayPoint::new(0, 8)..DisplayPoint::new(0, 12),
],
ctx,
)
.unwrap();
assert_eq!(editor.selections(ctx).len(), 2);
// Simulate typing in "pyaar" into the IME and then selecting "प्यार" as the candidate.
editor.set_marked_text("pyaar", &(5..5), ctx);
for selected_text in editor.selected_text_strings(ctx).iter() {
assert_eq!(selected_text, "pyaar");
}
editor.ime_commit("प्यार", ctx);
assert_eq!(editor.buffer_text(ctx), "प्यार is प्यार".to_owned());
editor
});
});
}
#[test]
fn test_set_marked_text_vim_normal_mode() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
app.add_window(WindowStyle::NotStealFocus, |ctx| {
let editor_options = EditorOptions {
supports_vim_mode: true,
..Default::default()
};
let mut editor = EditorView::new_with_base_text(
"This text should remain unchanged",
editor_options,
ctx,
);
editor
.select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx)
.unwrap();
// Set vim to normal mode.
editor.vim_keystroke(&Keystroke::parse("escape").unwrap(), ctx);
assert_eq!(editor.vim_mode(ctx), Some(VimMode::Normal));
// Simulate typing in "Om Shanti Om" into the IME and then selecting "ॐ शांति ॐ" as the candidate.
// Since we're in normal mode, we don't expect the text to change at all.
editor.set_marked_text("om shanti om", &(10..10), ctx);
assert_eq!(editor.selected_text(ctx), "");
assert_eq!(
editor.buffer_text(ctx),
"This text should remain unchanged".to_owned()
);
editor.ime_commit("ॐ शांति ॐ", ctx);
assert_eq!(
editor.buffer_text(ctx),
"This text should remain unchanged".to_owned()
);
editor
});
});
}
#[test]
fn test_set_marked_text_vim_insert_mode() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let _guard = FeatureFlag::ImeMarkedText.override_enabled(true);
app.add_window(WindowStyle::NotStealFocus, |ctx| {
let editor_options = EditorOptions {
supports_vim_mode: true,
..Default::default()
};
let mut editor = EditorView::new_with_base_text(
" is the best Bollywood movie ever created.",
editor_options,
ctx,
);
editor
.select_ranges(vec![DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)], ctx)
.unwrap();
// Set vim to normal mode.
assert_eq!(editor.vim_mode(ctx), Some(VimMode::Insert));
// Simulate typing in "Om Shanti Om" into the IME and then selecting "ॐ शांति ॐ" as the candidate.
// Since we're in insert mode, we don't expect the text to be inserted.
editor.set_marked_text("om shanti om", &(10..10), ctx);
assert_eq!(editor.selected_text(ctx), "om shanti om");
assert_eq!(
editor.buffer_text(ctx),
"om shanti om is the best Bollywood movie ever created.".to_owned()
);
editor.ime_commit("ॐ शांति ॐ", ctx);
assert_eq!(
editor.buffer_text(ctx),
"ॐ शांति ॐ is the best Bollywood movie ever created.".to_owned()
);
editor
});
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
use super::{time, Buffer};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::ops::Range;
use string_offset::CharOffset;
use time::Lamport;
#[derive(Clone, Eq, PartialEq, Debug, Hash, Serialize, Deserialize)]
pub enum Anchor {
Start,
End,
Middle {
insertion_id: Lamport,
offset: CharOffset,
bias: AnchorBias,
},
}
#[derive(Clone, Eq, PartialEq, Debug, Hash, Serialize, Deserialize)]
pub enum AnchorBias {
Left,
Right,
}
impl PartialOrd for AnchorBias {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AnchorBias {
fn cmp(&self, other: &Self) -> Ordering {
use AnchorBias::*;
if self == other {
return Ordering::Equal;
}
match (self, other) {
(Left, _) => Ordering::Less,
(Right, _) => Ordering::Greater,
}
}
}
impl Anchor {
pub fn cmp(&self, other: &Anchor, buffer: &Buffer) -> Result<Ordering> {
if self == other {
return Ok(Ordering::Equal);
}
Ok(match (self, other) {
(Anchor::Start, _) | (_, Anchor::End) => Ordering::Less,
(Anchor::End, _) | (_, Anchor::Start) => Ordering::Greater,
(
Anchor::Middle {
offset: self_offset,
bias: self_bias,
..
},
Anchor::Middle {
offset: other_offset,
bias: other_bias,
..
},
) => buffer
.fragment_id_for_anchor(self)?
.cmp(buffer.fragment_id_for_anchor(other)?)
.then_with(|| self_offset.cmp(other_offset))
.then_with(|| self_bias.cmp(other_bias)),
})
}
pub fn observed(&self, buffer: &Buffer) -> bool {
match self {
Anchor::Start | Anchor::End => true,
Anchor::Middle { insertion_id, .. } => buffer.versions().observed(insertion_id),
}
}
}
pub trait AnchorRangeExt {
fn cmp(&self, b: &Range<Anchor>, buffer: &Buffer) -> Result<Ordering>;
}
impl AnchorRangeExt for Range<Anchor> {
fn cmp(&self, other: &Range<Anchor>, buffer: &Buffer) -> Result<Ordering> {
Ok(match self.start.cmp(&other.start, buffer)? {
Ordering::Equal => other.end.cmp(&self.end, buffer)?,
ord => ord,
})
}
}
@@ -0,0 +1,69 @@
use super::time::ReplicaId;
use super::Operation;
use std::collections::HashSet;
/// An operation queue to defer buffer edits
/// that cannot yet be applied.
#[cfg_attr(test, derive(Clone))]
pub struct DeferredOperations {
/// The set of replica IDs for which operations
/// are being deferred.
replica_ids: HashSet<ReplicaId>,
/// The set of operations that are being deferred.
///
/// This list must stay ordered by the lamport timestamp
/// of the edits to avoid starvation. Specifically,
/// if edit B is causally dependent on edit A, then
/// lamport(B) > lamport(A). So if the operations are
/// processed in order, then consumers can guarantee that causally
/// dependent, deferred ops will not be starved (even if they
/// are very backed up). On the other hand, if edit B is _not_
/// causally dependent on edit A, then it doesn't matter whether
/// we process edit A or edit B first.
///
/// This ordering invariant allows consumers to [`Self::drain`] once
/// rather than repeatedly drain and apply.
operations: Vec<Operation>,
}
impl DeferredOperations {
pub fn new() -> Self {
Self {
replica_ids: HashSet::new(),
operations: vec![],
}
}
/// Empties the operation queue, returning an ordered
/// vector of operations that were previously deferred.
pub fn drain(&mut self) -> Vec<Operation> {
self.replica_ids = HashSet::new();
std::mem::take(&mut self.operations)
}
/// Extends the set of operations that need to be deferred.
///
/// There is intentionally no `push` API for a single element.
/// Callers are encouraged to collect the operations that need to
/// deferred and batch-push them because extending the operation queue
/// is expensive.
pub fn extend(&mut self, operations: Vec<Operation>) {
for op in operations {
self.replica_ids.insert(op.replica_id().clone());
self.operations.push(op);
}
self.operations
.sort_unstable_by_key(|op| op.lamport_timestamp().clone());
}
/// Returns true iff there are operations in the queue that originated from `replica_id`.
pub fn replica_deferred(&self, replica_id: &ReplicaId) -> bool {
self.replica_ids.contains(replica_id)
}
}
#[cfg(test)]
#[path = "deferred_ops_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use crate::editor::view::model::buffer::time::ReplicaId;
use crate::editor::view::model::buffer::EditOperation;
use super::super::time::{Global, Lamport};
use super::{DeferredOperations, Operation};
use itertools::Itertools;
use string_offset::CharOffset;
fn edit_operation(lamport: Lamport) -> Operation {
Operation::Edit(EditOperation {
lamport_timestamp: lamport.clone(),
versions: Global::new(),
start_id: lamport.clone(),
start_character_offset: CharOffset::from(0),
end_id: lamport,
end_character_offset: CharOffset::from(0),
new_text: String::from(""),
})
}
#[test]
fn test_ordering() {
let mut ops = DeferredOperations::new();
let edits = vec![
edit_operation(Lamport {
replica_id: ReplicaId::new(1),
value: 10.into(),
}),
edit_operation(Lamport {
replica_id: ReplicaId::new(5),
value: 2.into(),
}),
edit_operation(Lamport {
replica_id: ReplicaId::new(3),
value: 20.into(),
}),
edit_operation(Lamport {
replica_id: ReplicaId::new(9),
value: 30.into(),
}),
edit_operation(Lamport {
replica_id: ReplicaId::new(2),
value: 1.into(),
}),
edit_operation(Lamport {
replica_id: ReplicaId::new(1),
value: 2.into(),
}),
];
ops.extend(edits.clone());
let drained = ops.drain();
let expected = edits
.into_iter()
.sorted_by_key(|k| k.lamport_timestamp().clone())
.collect_vec();
assert_eq!(drained, expected);
}
#[test]
fn test_replica_deferred() {
let mut ops = DeferredOperations::new();
let replica_id = ReplicaId::new(1);
assert!(!ops.replica_deferred(&replica_id));
ops.extend(vec![edit_operation(Lamport {
replica_id: replica_id.clone(),
value: 0.into(),
})]);
assert!(ops.replica_deferred(&replica_id));
assert!(!ops.replica_deferred(&ReplicaId::new(2)));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
use super::{CharOffset, Point};
use itertools::Either;
use std::iter::Peekable;
use warpui::text::{
word_boundaries::WordBoundariesApproach, words::is_subword_boundary_char, TextBuffer,
};
pub struct SubwordBoundaries<'a, T: TextBuffer + ?Sized> {
offset: CharOffset,
chars: Peekable<Either<T::Chars<'a>, T::CharsReverse<'a>>>,
char_window: CharWindow,
buffer: &'a T,
approach: WordBoundariesApproach,
in_word: bool,
done: bool,
}
impl<'a, T: TextBuffer + ?Sized> SubwordBoundaries<'a, T> {
/// Returns an iterator for the beginnings of the subwords in the buffer.
pub fn forward_subword_starts(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
let mut peekable_chars = Either::Left(chars).peekable();
let first = peekable_chars.next();
let second = peekable_chars.next();
let third = peekable_chars.next();
Self {
offset,
buffer,
chars: peekable_chars,
char_window: CharWindow::new(first, second, third),
in_word: true,
approach: WordBoundariesApproach::ForwardWordStarts,
done: false,
}
}
/// Returns an iterator for the ends of the subwords in the buffer.
pub fn forward_subword_ends_exclusive(
offset: CharOffset,
chars: T::Chars<'a>,
buffer: &'a T,
) -> Self {
let mut peekable_chars = Either::Left(chars).peekable();
let first = peekable_chars.next();
let second = peekable_chars.next();
let third = peekable_chars.next();
Self {
offset,
buffer,
chars: peekable_chars,
char_window: CharWindow::new(first, second, third),
in_word: false,
approach: WordBoundariesApproach::ForwardWordEnds,
done: false,
}
}
/// Returns a backwards iterator for the beginning of subwords in the buffer.
pub fn backward_subword_starts_exclusive(
offset: CharOffset,
chars: T::CharsReverse<'a>,
buffer: &'a T,
) -> Self {
let mut peekable_chars = Either::Right(chars).peekable();
let first = peekable_chars.next();
let second = peekable_chars.next();
let third = peekable_chars.next();
Self {
offset,
buffer,
chars: peekable_chars,
char_window: CharWindow::new(first, second, third),
in_word: false,
approach: WordBoundariesApproach::BackwardWordStarts,
done: false,
}
}
/// Move to the next character and update the offset.
fn step(&mut self) {
let new_char = self.chars.next();
self.char_window.forward(new_char.to_owned());
match self.approach {
WordBoundariesApproach::ForwardWordStarts | WordBoundariesApproach::ForwardWordEnds => {
self.offset += 1;
}
WordBoundariesApproach::BackwardWordStarts => {
self.offset -= 1;
}
}
}
/// Helper for the `Iterator::next()` implementation.
/// Moves forward through the string and returns the start of the next
/// subword.
fn next_forward_starts(&mut self) -> Option<<Self as Iterator>::Item> {
while let Some(c) = self.char_window.first() {
if self.in_word {
// We're in a word, but are we in a subword?
if is_subword_boundary_char(c) {
// c isn't part of a subword.
self.in_word = false;
self.step();
} else if c.is_uppercase() {
if let Some(c_next) = self.char_window.second() {
// If c is upper and c_next is lower, then
// c marks the start of a Capitalized word.
if c_next.is_lowercase() {
let point = self.buffer.to_point(self.offset).ok();
self.step(); // to avoid getting stuck
return point;
} else if c_next.is_uppercase() {
// peek once more, to see if c is the "X" in "XYz"
if let Some(_c_next_next) = self.char_window.third() {
self.step();
} else {
// we've reached the last character in the string,
// and since it's uppercase it should be treated
// as the start of a one-letter word.
self.step();
return self.buffer.to_point(self.offset).ok();
}
} else {
self.step();
}
} else {
// c_next wasn't available.
self.step();
}
} else if c.is_lowercase() {
self.step();
if let Some(c_next) = self.char_window.first() {
if c_next.is_uppercase() {
let point = self.buffer.to_point(self.offset).ok();
self.step();
return point;
}
}
} else {
self.step();
}
// Still haven't entered a word.
} else if is_subword_boundary_char(c) {
self.step();
// Just entered a word.
} else {
self.in_word = true;
let point = self.buffer.to_point(self.offset).ok();
self.step();
return point;
}
}
None
}
/// Helper for the `Iterator::next()` implementation.
/// Moves forward through the string and returns the end of the next
/// subword.
fn next_forward_ends(&mut self) -> Option<<Self as Iterator>::Item> {
while let Some(c) = self.char_window.first() {
if self.in_word && is_subword_boundary_char(c) {
// We are in a word, but the next character is _not_ in a word,
// so we have found the boundary.
self.in_word = false;
return self.buffer.to_point(self.offset).ok();
}
if !self.in_word && !is_subword_boundary_char(c) {
self.in_word = true;
}
if self.in_word {
if let Some(c_next) = self.char_window.second() {
if c.is_lowercase() && c_next.is_uppercase() {
// c is the end of a word, and c_next begins the next word.
self.step();
return self.buffer.to_point(self.offset).ok();
} else if c.is_uppercase() && c_next.is_uppercase() {
// c_next could be part of an all-caps word
// or the start of a new Capitalized word
if let Some(c_next_next) = self.char_window.third() {
if c_next_next.is_lowercase() {
self.step();
return self.buffer.to_point(self.offset).ok();
}
}
}
}
}
self.step();
}
None
}
/// Helper for the `Iterator::next()` implementation.
/// Moves backward through the string and returns the start of the closest
/// subword.
fn next_backward_starts(&mut self) -> Option<<Self as Iterator>::Item> {
while let Some(c) = self.char_window.first() {
if self.in_word && is_subword_boundary_char(c) {
self.in_word = false;
let point = self.buffer.to_point(self.offset).ok();
self.step();
return point;
}
if !self.in_word && !is_subword_boundary_char(c) {
self.in_word = true;
}
if self.in_word {
if let Some(c_next) = self.char_window.second() {
if c.is_lowercase() && c_next.is_uppercase() {
// c_next is the start of the c's subword.
self.step();
self.step();
let point = self.buffer.to_point(self.offset).ok();
self.step(); // to avoid re-designating this character as a start
return point;
} else if c.is_uppercase() && c_next.is_lowercase() {
// c is the start Capitalized or Uppercase subword,
// and c_next is the end of a Capitalized or lowercase
// subword to the right of it.
self.step();
let point = self.buffer.to_point(self.offset).ok();
return point;
}
}
}
self.step();
}
None
}
}
impl<T: TextBuffer + ?Sized> Iterator for SubwordBoundaries<'_, T> {
type Item = Point;
fn next(&mut self) -> Option<Self::Item> {
if let Some(point) = match self.approach {
WordBoundariesApproach::ForwardWordStarts => self.next_forward_starts(),
WordBoundariesApproach::ForwardWordEnds => self.next_forward_ends(),
WordBoundariesApproach::BackwardWordStarts => self.next_backward_starts(),
} {
return Some(point);
}
// We have consumed all of the characters in the given direction. However, we should also
// treat the end (or beginning if backward) of the buffer as a word boundary. We only want
// to return that once, however, so we mark ourselves as done afterwards.
if self.done {
None
} else {
self.done = true;
self.buffer.to_point(self.offset).ok()
}
}
}
/// Storage for characters from the buffer, used by the `SubwordBoundaries`
/// iterator to find the start and end of subwords.
struct CharWindow {
/// A store of characters retreived from the `chars` iterator.
///
/// `char_window[0]`: character at the current offset.
///
/// `char_window[1]`: character after the current offset.
///
/// `char_window[2]`: two characters after the current offset.
window: Vec<Option<char>>,
}
impl CharWindow {
fn new(first: Option<char>, second: Option<char>, third: Option<char>) -> Self {
Self {
window: vec![first, second, third],
}
}
fn forward(&mut self, new_char: Option<char>) {
self.window.remove(0);
self.window.push(new_char);
}
fn first(&self) -> Option<char> {
self.window.first().unwrap_or(&None).to_owned()
}
fn second(&self) -> Option<char> {
self.window.get(1).unwrap_or(&None).to_owned()
}
fn third(&self) -> Option<char> {
self.window.get(2).unwrap_or(&None).to_owned()
}
}
#[cfg(test)]
#[path = "subword_boundaries_tests.rs"]
mod tests;
@@ -0,0 +1,287 @@
use super::super::Buffer;
use warpui::text::point::Point;
#[test]
fn test_subword_boundaries_forward_starts() {
let mut buffer: Buffer;
let mut starts: Vec<Point>;
let mut starts_expected: Vec<Point>;
buffer = Buffer::new("snake_case");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 6), Point::new(0, 10)];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("camelCase");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 5), Point::new(0, 9)];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("ALetter");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 1), Point::new(0, 7)];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("endWithA");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 3), Point::new(0, 7), Point::new(0, 8)];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("ABcD");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 1), Point::new(0, 3), Point::new(0, 4)];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("oneTwo_threeFour");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 3),
Point::new(0, 7),
Point::new(0, 12),
Point::new(0, 16),
];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("s_hOrt_Word");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 2),
Point::new(0, 3),
Point::new(0, 7),
Point::new(0, 11),
];
assert_eq!(starts, starts_expected);
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
starts = buffer
.subword_starts_from_offset(Point::zero())
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 5),
Point::new(0, 7),
Point::new(0, 10),
Point::new(0, 15),
Point::new(0, 20),
Point::new(0, 31),
Point::new(0, 34),
Point::new(0, 37),
Point::new(0, 43),
Point::new(0, 52),
];
assert_eq!(starts, starts_expected);
}
#[test]
fn test_subword_boundaries_forward_ends() {
let mut buffer: Buffer;
let mut ends: Vec<Point>;
let mut ends_expected: Vec<Point>;
buffer = Buffer::new("snake_case");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![Point::new(0, 5), Point::new(0, 10)];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("camelCase");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![Point::new(0, 5), Point::new(0, 9)];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("ALetter");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![Point::new(0, 1), Point::new(0, 7)];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("endWithA");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![Point::new(0, 3), Point::new(0, 7), Point::new(0, 8)];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("ABcD");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![Point::new(0, 1), Point::new(0, 3), Point::new(0, 4)];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("oneTwo_threeFour");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![
Point::new(0, 3),
Point::new(0, 6),
Point::new(0, 12),
Point::new(0, 16),
];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("s_hOrt_Word");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![
Point::new(0, 1),
Point::new(0, 3),
Point::new(0, 6),
Point::new(0, 11),
];
assert_eq!(ends, ends_expected);
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
ends = buffer
.subword_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
ends_expected = vec![
Point::new(0, 4),
Point::new(0, 6),
Point::new(0, 9),
Point::new(0, 14),
Point::new(0, 19),
Point::new(0, 31),
Point::new(0, 34),
Point::new(0, 37),
Point::new(0, 41),
Point::new(0, 51),
Point::new(0, 52),
];
assert_eq!(ends, ends_expected);
}
#[test]
fn test_subword_boundaries_backward_starts() {
let mut buffer: Buffer;
let mut starts: Vec<Point>;
let mut starts_expected: Vec<Point>;
buffer = Buffer::new("snake_case");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 10))
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 0), Point::new(0, 6)];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("camelCase");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 9))
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 0), Point::new(0, 5)];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("ALetter");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 7))
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 0), Point::new(0, 1)];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("endWithA");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 8))
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 0), Point::new(0, 3), Point::new(0, 7)];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("ABcD");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 4))
.unwrap()
.collect();
starts_expected = vec![Point::new(0, 0), Point::new(0, 1), Point::new(0, 3)];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("oneTwo_threeFour");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 16))
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 0),
Point::new(0, 3),
Point::new(0, 7),
Point::new(0, 12),
];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("s_hOrt_Word");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 11))
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 0),
Point::new(0, 2),
Point::new(0, 3),
Point::new(0, 7),
];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
buffer = Buffer::new("test/c/ab/word_with_underscoresAndUHHCaps {восибing}");
starts = buffer
.subword_backward_starts_from_offset_exclusive(Point::new(0, 52))
.unwrap()
.collect();
starts_expected = vec![
Point::new(0, 0),
Point::new(0, 5),
Point::new(0, 7),
Point::new(0, 10),
Point::new(0, 15),
Point::new(0, 20),
Point::new(0, 31),
Point::new(0, 34),
Point::new(0, 37),
Point::new(0, 43),
];
starts_expected.reverse();
assert_eq!(starts, starts_expected);
}
+87
View File
@@ -0,0 +1,87 @@
/// Test utilities for testing the buffer.
use super::time::ReplicaId;
use rand::Rng;
use std::collections::BTreeMap;
#[cfg(test)]
pub(crate) struct Network<T: Clone> {
inboxes: BTreeMap<ReplicaId, Vec<Envelope<T>>>,
all_messages: Vec<T>,
}
#[derive(Clone)]
struct Envelope<T: Clone> {
message: T,
sender: ReplicaId,
}
impl<T: Clone> Network<T> {
pub fn new() -> Self {
Network {
inboxes: BTreeMap::new(),
all_messages: Vec::new(),
}
}
pub fn add_peer(&mut self, id: ReplicaId) {
self.inboxes.insert(id, Vec::new());
}
pub fn is_idle(&self) -> bool {
self.inboxes.values().all(|i| i.is_empty())
}
pub fn broadcast<R>(&mut self, sender: ReplicaId, messages: Vec<T>, rng: &mut R)
where
R: Rng,
{
for (replica, inbox) in self.inboxes.iter_mut() {
if replica != &sender {
for message in &messages {
let min_index = inbox
.iter()
.enumerate()
.rev()
.find_map(|(index, envelope)| {
if sender == envelope.sender {
Some(index + 1)
} else {
None
}
})
.unwrap_or(0);
// Insert one or more duplicates of this message *after* the previous
// message delivered by this replica.
for _ in 0..rng.gen_range(1..4) {
let insertion_index = rng.gen_range(min_index..inbox.len() + 1);
inbox.insert(
insertion_index,
Envelope {
message: message.clone(),
sender: sender.clone(),
},
);
}
}
}
}
self.all_messages.extend(messages);
}
pub fn has_unreceived(&self, receiver: &ReplicaId) -> bool {
!self.inboxes[receiver].is_empty()
}
pub fn receive<R>(&mut self, receiver: ReplicaId, rng: &mut R) -> Vec<T>
where
R: Rng,
{
let inbox = self.inboxes.get_mut(&receiver).unwrap();
let count = rng.gen_range(0..inbox.len() + 1);
inbox
.drain(0..count)
.map(|envelope| envelope.message)
.collect()
}
}
+394
View File
@@ -0,0 +1,394 @@
use arrayvec::ArrayVec;
use num_traits::SaturatingSub;
use std::{
cmp,
fmt::{self, Debug},
ops::{Bound, Index, Range, RangeBounds},
rc::Rc,
};
use string_offset::{ByteOffset, CharOffset};
use sum_tree::{self, SeekBias, SumTree};
use warpui::text::point::Point;
use warpui::text_layout::TextStyle;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum Run {
Newline,
Chars { len: usize, char_size: u8 },
}
impl sum_tree::Item for Run {
type Summary = TextSummary;
fn summary(&self) -> Self::Summary {
match *self {
Run::Newline => TextSummary {
chars: 1.into(),
bytes: 1.into(),
lines: Point::new(1, 0),
first_line_len: 0,
rightmost_point: Point::new(0, 0),
},
Run::Chars { len, char_size } => TextSummary {
chars: len.into(),
bytes: (len * char_size as usize).into(),
lines: Point::new(0, len as u32),
first_line_len: len as u32,
rightmost_point: Point::new(0, len as u32),
},
}
}
}
impl Run {
fn char_size(&self) -> u8 {
match self {
Run::Newline => 1,
Run::Chars { char_size, .. } => *char_size,
}
}
}
/// A summary of text locations.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TextSummary {
pub chars: CharOffset,
pub bytes: ByteOffset,
pub lines: Point,
pub first_line_len: u32,
pub rightmost_point: Point,
}
impl<'a> std::ops::AddAssign<&'a Self> for TextSummary {
fn add_assign(&mut self, other: &'a Self) {
let joined_line_len = self.lines.column + other.first_line_len;
if joined_line_len > self.rightmost_point.column {
self.rightmost_point = Point::new(self.lines.row, joined_line_len);
}
if other.rightmost_point.column > self.rightmost_point.column {
self.rightmost_point = self.lines + other.rightmost_point;
}
if self.lines.row == 0 {
self.first_line_len += other.first_line_len;
}
self.chars += other.chars;
self.bytes += other.bytes;
self.lines += other.lines;
}
}
impl std::ops::AddAssign<Self> for TextSummary {
fn add_assign(&mut self, other: Self) {
*self += &other;
}
}
impl sum_tree::Dimension<'_, TextSummary> for TextSummary {
fn add_summary(&mut self, summary: &TextSummary) {
*self += summary;
}
}
impl sum_tree::Dimension<'_, TextSummary> for Point {
fn add_summary(&mut self, summary: &TextSummary) {
*self += summary.lines;
}
}
impl sum_tree::Dimension<'_, TextSummary> for ByteOffset {
fn add_summary(&mut self, summary: &TextSummary) {
*self += summary.bytes
}
}
impl sum_tree::Dimension<'_, TextSummary> for CharOffset {
fn add_summary(&mut self, summary: &TextSummary) {
*self += summary.chars;
}
}
#[derive(Clone)]
pub struct Text {
text: Rc<str>,
runs: SumTree<Run>,
range: Range<CharOffset>,
pub text_style: Option<TextStyle>,
}
impl Text {
pub fn new(text: impl Into<String>, text_style: Option<TextStyle>) -> Self {
let mut text = Text::from(text.into());
text.text_style = text_style;
text
}
pub fn with_text_style(mut self, text_style: impl Into<Option<TextStyle>>) -> Self {
self.text_style = text_style.into();
self
}
pub fn fallback_text_style_with<F>(&mut self, fallback: F)
where
F: FnOnce() -> Option<TextStyle>,
{
if self.text_style.is_none() {
self.text_style = fallback();
}
}
pub fn text_style(&self) -> Option<TextStyle> {
self.text_style
}
}
impl From<String> for Text {
fn from(text: String) -> Self {
let mut runs = Vec::new();
let mut chars_len = 0;
let mut run_char_size = 0;
let mut run_chars = 0;
let mut chars = text.chars();
loop {
let ch = chars.next();
let ch_size = ch.map_or(0, |ch| ch.len_utf8());
if run_chars != 0 && (ch.is_none() || ch == Some('\n') || run_char_size != ch_size) {
runs.push(Run::Chars {
len: run_chars,
char_size: run_char_size as u8,
});
run_chars = 0;
}
run_char_size = ch_size;
match ch {
Some('\n') => runs.push(Run::Newline),
Some(_) => run_chars += 1,
None => break,
}
chars_len += 1;
}
let mut tree = SumTree::new();
tree.extend(runs);
Text {
text: text.into(),
runs: tree,
range: 0.into()..chars_len.into(),
text_style: None,
}
}
}
impl<'a> From<&'a str> for Text {
fn from(text: &'a str) -> Self {
Self::from(String::from(text))
}
}
impl Debug for Text {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Text")
.field("text", &self.text)
.field("range", &self.range)
.field("text_style", &self.text_style)
.finish()
}
}
impl PartialEq for Text {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
}
}
impl Eq for Text {}
impl<T: RangeBounds<CharOffset>> Index<T> for Text {
type Output = str;
fn index(&self, range: T) -> &Self::Output {
let start = match range.start_bound() {
Bound::Included(start) => cmp::min(self.range.start + *start, self.range.end),
Bound::Excluded(_) => unimplemented!(),
Bound::Unbounded => self.range.start,
};
let end = match range.end_bound() {
Bound::Included(end) => cmp::min(self.range.start + *end + 1, self.range.end),
Bound::Excluded(end) => cmp::min(self.range.start + *end, self.range.end),
Bound::Unbounded => self.range.end,
};
let byte_start = self.abs_byte_offset_for_offset(start);
let byte_end = self.abs_byte_offset_for_offset(end);
&self.text[byte_start.as_usize()..byte_end.as_usize()]
}
}
impl Text {
pub fn range(&self) -> Range<CharOffset> {
self.range.clone()
}
pub fn as_str(&self) -> &str {
&self[..]
}
pub fn slice<T: RangeBounds<CharOffset>>(&self, range: T) -> Text {
let start = match range.start_bound() {
Bound::Included(start) => cmp::min(self.range.start + *start, self.range.end),
Bound::Excluded(_) => unimplemented!(),
Bound::Unbounded => self.range.start,
};
let end = match range.end_bound() {
Bound::Included(end) => cmp::min(self.range.start + *end + 1, self.range.end),
Bound::Excluded(end) => cmp::min(self.range.start + *end, self.range.end),
Bound::Unbounded => self.range.end,
};
Text {
text: self.text.clone(),
runs: self.runs.clone(),
range: start..end,
text_style: self.text_style,
}
}
pub fn line_len(&self, row: u32) -> u32 {
let mut cursor = self.runs.cursor::<CharOffset, Point>();
cursor.seek(&self.range.start, SeekBias::Right);
let absolute_row = cursor.start().row + row;
let mut cursor = self.runs.cursor::<Point, CharOffset>();
cursor.seek(&Point::new(absolute_row, 0), SeekBias::Right);
let prefix_len = self.range.start.saturating_sub(cursor.start());
let line_len =
cursor.summary::<CharOffset>(&Point::new(absolute_row + 1, 0), SeekBias::Left);
let suffix_len = cursor.start().saturating_sub(&self.range.end);
line_len
.saturating_sub(&prefix_len)
.saturating_sub(&suffix_len)
.as_usize() as u32
}
pub fn len(&self) -> CharOffset {
self.range.end - self.range.start
}
pub fn byte_len(&self) -> ByteOffset {
self.as_str().len().into()
}
pub fn is_empty(&self) -> bool {
self.range.is_empty()
}
pub fn lines(&self) -> Point {
self.abs_point_for_offset(self.range.end) - self.abs_point_for_offset(self.range.start)
}
pub fn rightmost_point(&self) -> Point {
let lines = self.lines();
let mut candidates = ArrayVec::<Point, 3>::new();
candidates.push(lines);
if lines.row > 0 {
candidates.push(Point::new(0, self.line_len(0)));
if lines.row > 1 {
let mut cursor = self.runs.cursor::<CharOffset, Point>();
cursor.seek(&self.range.start, SeekBias::Right);
let absolute_start_row = cursor.start().row;
let mut cursor = self.runs.cursor::<Point, CharOffset>();
cursor.seek(&Point::new(absolute_start_row + 1, 0), SeekBias::Right);
let summary = cursor.summary::<TextSummary>(
&Point::new(absolute_start_row + lines.row, 0),
SeekBias::Left,
);
candidates.push(Point::new(1, 0) + summary.rightmost_point);
}
}
candidates.into_iter().max_by_key(|p| p.column).unwrap()
}
pub fn point_for_offset(&self, offset: CharOffset) -> Point {
self.abs_point_for_offset(self.range.start + offset)
- self.abs_point_for_offset(self.range.start)
}
pub fn offset_for_point(&self, point: Point) -> CharOffset {
let mut cursor = self.runs.cursor::<Point, TextSummary>();
let abs_point = self.abs_point_for_offset(self.range.start) + point;
cursor.seek(&abs_point, SeekBias::Right);
let overshoot = abs_point - cursor.start().lines;
let abs_offset = cursor.start().chars.as_usize() + overshoot.column as usize;
CharOffset::from(abs_offset) - self.range.start
}
pub fn byte_offset_for_point(&self, point: Point) -> ByteOffset {
// Compute the number of characters the `point` is from the start of the text.
let character_offset = self.offset_for_point(point);
let num_bytes_to_point =
self.abs_byte_offset_for_offset(character_offset + self.range.start);
let num_bytes_to_start = self.abs_byte_offset_for_offset(self.range.start);
num_bytes_to_point - num_bytes_to_start
}
pub fn summary(&self) -> TextSummary {
TextSummary {
chars: self.range.end - self.range.start,
bytes: self.abs_byte_offset_for_offset(self.range.end)
- self.abs_byte_offset_for_offset(self.range.start),
lines: self.abs_point_for_offset(self.range.end)
- self.abs_point_for_offset(self.range.start),
first_line_len: self.line_len(0),
rightmost_point: self.rightmost_point(),
}
}
/// Computes the number of equivalent chars from the start of the `Text` given the number of
/// bytes from the start of the `Text`.
pub fn char_offset_for_byte_offset(&self, byte_offset: ByteOffset) -> CharOffset {
let mut cursor = self.runs.cursor::<ByteOffset, TextSummary>();
let abs_byte_offset = self.abs_byte_offset_for_offset(self.range.start) + byte_offset;
cursor.seek(&abs_byte_offset, SeekBias::Right);
let overshoot = abs_byte_offset - cursor.start().bytes;
// Determine the number of characters based on the char size of the fragment.
let absolute_chars = cursor.start().chars
+ ((overshoot).as_usize() / (cursor.item().map_or(1, |run| run.char_size()) as usize));
// Convert character offset from the start of the text back to a relative offset back to the
// the start of the range.
absolute_chars - self.range.start
}
fn abs_point_for_offset(&self, offset: CharOffset) -> Point {
let mut cursor = self.runs.cursor::<CharOffset, TextSummary>();
cursor.seek(&offset, SeekBias::Right);
let overshoot = (offset - cursor.start().chars).as_usize() as u32;
cursor.start().lines + Point::new(0, overshoot)
}
/// Computes the byte offset from the start of the `self.text` given a character offset from
/// the start of `self.text`.
fn abs_byte_offset_for_offset(&self, offset: CharOffset) -> ByteOffset {
let mut cursor = self.runs.cursor::<CharOffset, TextSummary>();
cursor.seek(&offset, SeekBias::Right);
let overshoot = (offset - cursor.start().chars).as_usize();
cursor.start().bytes + (overshoot * cursor.item().map_or(0, |run| run.char_size()) as usize)
}
}
#[cfg(test)]
#[path = "text_test.rs"]
mod tests;
@@ -0,0 +1,155 @@
use super::*;
use std::collections::HashSet;
use std::iter::FromIterator;
#[test]
fn test_basic() {
let text = Text::from(String::from("ab\ncd€\nfghij\nkl¢m"));
assert_eq!(text.len(), 17.into());
assert_eq!(text.as_str(), "ab\ncd€\nfghij\nkl¢m");
assert_eq!(text.lines(), Point::new(3, 4));
assert_eq!(text.line_len(0), 2);
assert_eq!(text.line_len(1), 3);
assert_eq!(text.line_len(2), 5);
assert_eq!(text.line_len(3), 4);
assert_eq!(text.rightmost_point(), Point::new(2, 5));
assert_eq!(text.byte_offset_for_point(Point::new(1, 0)), 3.into());
assert_eq!(text.byte_offset_for_point(Point::new(3, 3)), 19.into());
assert_eq!(text.char_offset_for_byte_offset(3.into()), 3.into());
// The string is 20 bytes but only 17 characters.
assert_eq!(text.char_offset_for_byte_offset(20.into()), 17.into());
let b_to_g = text.slice(CharOffset::from(1)..CharOffset::from(9));
assert_eq!(b_to_g.as_str(), "b\ncd€\nfg");
assert_eq!(b_to_g.len(), 8.into());
assert_eq!(b_to_g.lines(), Point::new(2, 2));
assert_eq!(b_to_g.line_len(0), 1);
assert_eq!(b_to_g.line_len(1), 3);
assert_eq!(b_to_g.line_len(2), 2);
assert_eq!(b_to_g.line_len(3), 0);
assert_eq!(b_to_g.rightmost_point(), Point::new(1, 3));
assert_eq!(b_to_g.byte_offset_for_point(Point::new(1, 0)), 2.into());
assert_eq!(b_to_g.byte_offset_for_point(Point::new(2, 1)), 9.into());
assert_eq!(b_to_g.char_offset_for_byte_offset(6.into()), 4.into());
// The string is 10 bytes but only 8 characters.
assert_eq!(b_to_g.char_offset_for_byte_offset(9.into()), 7.into());
let d_to_i = text.slice(CharOffset::from(4)..CharOffset::from(11));
assert_eq!(d_to_i.as_str(), "d€\nfghi");
assert_eq!(&d_to_i[CharOffset::from(1)..CharOffset::from(5)], "\nfg");
assert_eq!(d_to_i.len(), 7.into());
assert_eq!(d_to_i.lines(), Point::new(1, 4));
assert_eq!(d_to_i.line_len(0), 2);
assert_eq!(d_to_i.line_len(1), 4);
assert_eq!(d_to_i.line_len(2), 0);
assert_eq!(d_to_i.rightmost_point(), Point::new(1, 4));
assert_eq!(d_to_i.byte_offset_for_point(Point::new(1, 0)), 5.into());
assert_eq!(d_to_i.byte_offset_for_point(Point::new(1, 3)), 8.into());
// A byte index in the middle of a character should return the character before.
assert_eq!(d_to_i.char_offset_for_byte_offset(1.into()), 1.into());
assert_eq!(d_to_i.char_offset_for_byte_offset(2.into()), 1.into());
// The string is 8 bytes but only 7 characters.
assert_eq!(d_to_i.char_offset_for_byte_offset(9.into()), 7.into());
let d_to_j = text.slice(CharOffset::from(4)..=CharOffset::from(11));
assert_eq!(d_to_j.as_str(), "d€\nfghij");
assert_eq!(&d_to_j[CharOffset::from(1)..], "\nfghij");
assert_eq!(d_to_j.len(), 8.into());
}
#[test]
fn test_random() {
use rand::prelude::*;
for seed in 0..100 {
println!("buffer::text seed: {seed}");
let rng = &mut StdRng::seed_from_u64(seed);
let len: i32 = rng.gen_range(0..50);
let mut string = String::new();
for _ in 0..len {
if rng.gen_ratio(1, 5) {
string.push('\n');
} else {
string.push(rng.gen());
}
}
let text = Text::from(string.clone());
for _ in 0..10 {
let start = CharOffset::from(rng.gen_range(0..text.len().as_usize() + 1));
let end = CharOffset::from(rng.gen_range(start.as_usize()..text.len().as_usize() + 2));
let string_slice = string
.chars()
.skip(start.as_usize())
.take(end.as_usize() - start.as_usize())
.collect::<String>();
let expected_line_endpoints = string_slice
.split('\n')
.enumerate()
.map(|(row, line)| Point::new(row as u32, line.chars().count() as u32))
.collect::<Vec<_>>();
let text_slice = text.slice(start..end);
assert_eq!(text_slice.lines(), lines(&string_slice));
let mut rightmost_points: HashSet<Point> = HashSet::new();
for endpoint in &expected_line_endpoints {
if let Some(rightmost_point) = rightmost_points.iter().next().cloned() {
if endpoint.column > rightmost_point.column {
rightmost_points.clear();
}
if endpoint.column >= rightmost_point.column {
rightmost_points.insert(*endpoint);
}
} else {
rightmost_points.insert(*endpoint);
}
assert_eq!(text_slice.line_len(endpoint.row), endpoint.column);
}
assert!(rightmost_points.contains(&text_slice.rightmost_point()));
for _ in 0..10 {
let offset = CharOffset::from(rng.gen_range(0..string_slice.chars().count() + 1));
let point = lines(
&string_slice
.chars()
.take(offset.as_usize())
.collect::<String>(),
);
assert_eq!(text_slice.point_for_offset(offset), point);
assert_eq!(text_slice.offset_for_point(point), offset);
if offset < string_slice.chars().count().into() {
assert_eq!(
&text_slice[offset..offset + 1],
String::from_iter(string_slice.chars().nth(offset.as_usize())).as_str()
);
}
}
}
}
}
pub fn lines(s: &str) -> Point {
let mut row = 0;
let mut column = 0;
for ch in s.chars() {
if ch == '\n' {
row += 1;
column = 0;
} else {
column += 1;
}
}
Point::new(row, column)
}
+171
View File
@@ -0,0 +1,171 @@
use serde::{Deserialize, Serialize};
use std::cmp::{self, Ordering};
use std::collections::HashMap;
use std::rc::Rc;
use uuid::Uuid;
const BASE_REPLICA_ID: &str = "0";
/// A unique ID assigned to every peer in the system.
#[derive(Clone, Hash, Eq, PartialEq, Debug, Ord, PartialOrd, Serialize, Deserialize)]
pub struct ReplicaId(Rc<String>);
impl ReplicaId {
pub fn new(id: impl ToString) -> Self {
let id = Self(Rc::new(id.to_string()));
debug_assert!(id.0.as_str() != BASE_REPLICA_ID);
id
}
/// Creates a sufficiently random id.
pub fn random() -> Self {
Self::new(Uuid::new_v4())
}
/// A sentinel replica ID for the base text.
/// The base text insertion is considered to be a replica-less edit.
pub fn base_replica_id() -> Self {
Self(Rc::new(BASE_REPLICA_ID.to_string()))
}
}
impl std::fmt::Display for ReplicaId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
/// A bare [lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp).
/// Prefer to use the full [`Lamport`] type unless the replica ID is known / fixed.
#[derive(
Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
pub struct LamportValue(usize);
impl LamportValue {
fn next(&self) -> Self {
LamportValue(self.0 + 1)
}
}
impl From<usize> for LamportValue {
fn from(val: usize) -> Self {
LamportValue(val)
}
}
/// A [lamport timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp).
///
/// Along with the bare [`LamportValue`], we also store the replica ID to identify
/// the origin of the event associated to this timestamp. This allows us to achieve
/// a total ordering of events (see https://en.wikipedia.org/wiki/Lamport_timestamp#Implications).
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct Lamport {
pub replica_id: ReplicaId,
pub value: LamportValue,
}
impl Lamport {
pub fn new(replica_id: ReplicaId) -> Self {
Self {
value: LamportValue::default(),
replica_id,
}
}
pub fn tick(&mut self) -> Self {
let timestamp = self.clone();
self.value.0 += 1;
timestamp
}
pub fn observe(&mut self, timestamp: &Self) {
self.value = cmp::max(self.value, timestamp.value).next();
}
pub fn replica_id(&self) -> ReplicaId {
self.replica_id.clone()
}
}
impl Ord for Lamport {
/// When comparing lamport timestamps, we break ties using the replica ID
/// to get a total ordering.
fn cmp(&self, other: &Self) -> Ordering {
(self.value, &self.replica_id).cmp(&(other.value, &other.replica_id))
}
}
impl PartialOrd for Lamport {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// A [version vector](https://en.wikipedia.org/wiki/Version_vector) to track
/// the latest lamport timestamp seen for every peer in the system.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Global(Rc<HashMap<ReplicaId, LamportValue>>);
impl Default for Global {
fn default() -> Self {
Self::new()
}
}
impl Global {
pub fn new() -> Self {
Global(Rc::new(HashMap::new()))
}
pub fn get(&self, replica_id: &ReplicaId) -> LamportValue {
self.0.get(replica_id).copied().unwrap_or_default()
}
pub fn observe(&mut self, timestamp: &Lamport) {
let map = Rc::make_mut(&mut self.0);
let value = map.entry(timestamp.replica_id.clone()).or_default();
*value = cmp::max(*value, timestamp.value);
}
pub fn observe_all(&mut self, other: &Self) {
for (replica_id, value) in other.0.as_ref() {
self.observe(&Lamport {
replica_id: replica_id.clone(),
value: *value,
});
}
}
pub fn observed(&self, timestamp: &Lamport) -> bool {
self.get(&timestamp.replica_id) >= timestamp.value
}
pub fn changed_since(&self, other: &Self) -> bool {
self.0
.iter()
.any(|(replica_id, value)| *value > other.get(replica_id))
}
}
impl PartialOrd for Global {
/// Returns Some(Ordering) iff the ordering is conclusive.
/// Otherwise, the version vectors being compared are
/// concurrent and so the ordering is undefined ([`None`]).
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
let mut global_ordering = Ordering::Equal;
for replica_id in self.0.keys().chain(other.0.keys()) {
let ordering = self.get(replica_id).cmp(&other.get(replica_id));
if ordering != Ordering::Equal {
if global_ordering == Ordering::Equal {
global_ordering = ordering;
} else if ordering != global_ordering {
return None;
}
}
}
Some(global_ordering)
}
}
+273
View File
@@ -0,0 +1,273 @@
use std::{
collections::{BTreeSet, HashMap},
time::Duration,
};
use bounded_vec_deque::BoundedVecDeque;
use instant::Instant;
use super::time::{Global, Lamport, LamportValue};
use crate::editor::view::{model::LocalSelections, PlainTextEditorViewAction};
/// The maximum time we will batch consecutive edits for the same [`Action`].
/// The "batch" here is not to be confused with the [`Buffer`]'s notion
/// of a "batch". A single undo / redo batched item might consist
/// of multiple buffer batched items.
const UNDO_REDO_BATCH_TIMER: Duration = Duration::from_millis(500);
/// The maximum size of the undo stack.
/// TODO: this could be substantially larger now that we don't store snapshots.
const DEFAULT_UNDO_STACK_CAPACITY: usize = 20;
/// An entry in the [`LocalUndoStack`].
#[derive(Clone)]
struct UndoStackEntry {
/// The set of operations that would be undone.
/// These numbers are lamport timestamps corresponding to the edit operations.
/// We don't need a full [`Lamport`] timestamp because the replica ID would be
/// redundant (this is only for local edits).
ops: Vec<LamportValue>,
/// A snapshot of the selection state after the ops.
selections: LocalSelections,
/// The action associated with this entry.
/// If this is the current item on the stack and the last
/// change in the current batch was a pure selection change, this will be
/// [`Action::CursorChanged`].
action: PlainTextEditorViewAction,
}
/// A stack of batched, local operations that can be undone / redone.
/// The undo stack batches consecutive records within a [`UNDO_REDO_BATCH_TIMER`]
/// period if:
/// - the records are associated to the same action
/// - the associated action is not atomic
/// - there aren't any undos (we're at the top of the stack)
#[derive(Clone)]
pub struct LocalUndoStack {
/// The stack itself.
stack: BoundedVecDeque<UndoStackEntry>,
/// Where we are in the stack currently.
/// As we undo / redo, the position into the stack
/// changes but the stack itself stays in tact.
current_index: usize,
/// The absolute time when the stack was last modified.
last_changed_at: Option<Instant>,
}
impl LocalUndoStack {
pub fn new(init_selections: LocalSelections) -> LocalUndoStack {
Self::new_with_capacity(init_selections, DEFAULT_UNDO_STACK_CAPACITY)
}
fn new_with_capacity(init_selections: LocalSelections, capacity: usize) -> LocalUndoStack {
let init_entry = UndoStackEntry {
ops: vec![],
selections: init_selections,
action: PlainTextEditorViewAction::ReplaceBuffer,
};
Self {
stack: BoundedVecDeque::from_iter([init_entry], capacity),
current_index: 0,
last_changed_at: None,
}
}
/// Records a selection change by updating the current item's selection snapshot.
pub fn record_selection_change(&mut self, selections: LocalSelections) {
self.stack[self.current_index].selections = selections;
self.stack[self.current_index].action = PlainTextEditorViewAction::CursorChanged;
}
/// Records an edit and potentially batches it with other edits if possible.
pub fn record_edit(
&mut self,
action: PlainTextEditorViewAction,
operations: impl Iterator<Item = LamportValue>,
selections: LocalSelections,
) {
let should_insert_new_entry = self.current_index == 0
|| self.current_index < self.stack.len() - 1
|| action.is_atomic()
|| Some(action) != self.last_action()
|| self
.last_changed_at
.is_none_or(|t| t.elapsed() >= UNDO_REDO_BATCH_TIMER);
// If the index is positioned somewhere other than the last item,
// this means that the user is making an edit after some undo'ing.
// Any of the items past the current index are not needed anymore.
self.stack.truncate(self.current_index + 1);
if should_insert_new_entry {
// Since we're using a [`BoundedVecDeque`], pushing to the
// back might remove from the front if the stack is at capacity.
self.stack.push_back(UndoStackEntry {
action,
ops: operations.collect(),
selections,
});
self.current_index = self.stack.len() - 1;
} else {
let back = self.stack.back_mut().unwrap();
back.ops.extend(operations);
back.selections = selections;
}
self.last_changed_at = Some(Instant::now());
}
/// Processes an undo and returns
/// 1. the set of operations that need to be undone
/// 2. the set of selections that need to be restored
pub fn undo(&mut self) -> Option<(Vec<LamportValue>, LocalSelections)> {
if self.current_index == 0 {
return None;
}
self.last_changed_at = None;
let ops = self.stack[self.current_index].ops.clone();
self.current_index -= 1;
let selections = self.stack[self.current_index].selections.clone();
Some((ops, selections))
}
/// Processes a redo and returns
/// 1. the set of operations that need to be redone
/// 2. the set of selections that need to be restored
pub fn redo(&mut self) -> Option<(Vec<LamportValue>, LocalSelections)> {
if self.current_index == self.stack.len() - 1 {
return None;
}
self.last_changed_at = None;
self.current_index += 1;
let UndoStackEntry {
ops, selections, ..
} = self.stack[self.current_index].clone();
Some((ops, selections))
}
/// Resets the undo stack to a single item with the given selections.
pub fn reset(&mut self, selections: LocalSelections) {
let init_entry = UndoStackEntry {
ops: vec![],
selections,
action: PlainTextEditorViewAction::ReplaceBuffer,
};
self.stack = BoundedVecDeque::from_iter([init_entry], DEFAULT_UNDO_STACK_CAPACITY);
self.last_changed_at = None;
self.current_index = 0;
}
/// Returns the action associated to the last item on the undo stack.
pub fn last_action(&self) -> Option<PlainTextEditorViewAction> {
if self.stack.len() == 1 {
return None;
};
self.stack.back().map(|e| e.action)
}
}
/// A history of local and remote undos.
#[derive(Clone)]
pub struct UndoHistory {
/// A mapping from an edit ID to the lamport timestamps of undo operations for that edit.
/// The values are bare lamport times because the replica ID of an undo operation will
/// always be the replica ID for the edit itself (one can only undo their own edits).
///
/// Suppose an undo operation of an edit `e` is defined as `undo(e)`.
/// Then, a redo of an edit operation `e` can be thought of as `undo(undo(e))`.
/// Using that line of logic, an edit is considered undone iff the number of undos are odd.
///
/// We use a [`BTreeSet`] to quickly query the max undo ts for a given edit
/// and to efficiently compute intersections with other sets (e.g. deletions).
map: HashMap<Lamport, BTreeSet<LamportValue>>,
}
impl UndoHistory {
pub fn new() -> Self {
UndoHistory {
map: HashMap::new(),
}
}
/// Returns the largest known undo timestamp for `edit`.
fn max_undo_ts(&self, edit_id: &Lamport) -> LamportValue {
self.map
.get(edit_id)
.and_then(|v| v.last().copied())
.unwrap_or_default()
}
/// Registers an undo for `edit`
pub fn undo(&mut self, edit_id: Lamport, undo_ts: LamportValue) {
// The undo timestamp should be larger than all undo timestamps
// so far for this edit and must be after the edit itself.
debug_assert!(undo_ts > self.max_undo_ts(&edit_id) && undo_ts > edit_id.value);
self.map.entry(edit_id).or_default().insert(undo_ts);
}
/// Returns true iff `edit` is considered undone.
/// An edit is considered undone iff it has an odd number of undos.
/// In other words, there exists some undo that wasn't redone.
pub fn is_edit_undone(&self, edit_id: &Lamport) -> bool {
self.map.get(edit_id).map_or(0, |v| v.len()) % 2 == 1
}
/// Returns true iff `edit` was undone when the version vector was `version`.
pub fn was_edit_undone(&self, edit_id: &Lamport, version: &Global) -> bool {
let num_undos = self
.map
.get(edit_id)
.iter()
.flat_map(|v| v.iter())
.take_while(|undo| {
version.observed(&Lamport {
replica_id: edit_id.replica_id(),
value: **undo,
})
})
.count();
num_undos % 2 == 1
}
}
impl PlainTextEditorViewAction {
/// Whether the action is atomic. An atomic action is one where a single event
/// is independently undoable. This differs from non-atomic actions (such as `INSERT_TEXT`)
/// where multiple events are coalesced and undone together if they occur within [`UNDO_REDO_BATCH_TIMER`] of each other.
pub fn is_atomic(&self) -> bool {
matches!(
self,
PlainTextEditorViewAction::Yank
| PlainTextEditorViewAction::AcceptCompletionSuggestion
| PlainTextEditorViewAction::NewLine
| PlainTextEditorViewAction::DeleteWordLeft
| PlainTextEditorViewAction::DeleteWordRight
| PlainTextEditorViewAction::AutoSuggestion
| PlainTextEditorViewAction::ReplaceBuffer
| PlainTextEditorViewAction::Paste
| PlainTextEditorViewAction::ClearLines
| PlainTextEditorViewAction::ClearAndCopyLines
| PlainTextEditorViewAction::DeleteAll
| PlainTextEditorViewAction::CutAll
| PlainTextEditorViewAction::InsertSelectedText
| PlainTextEditorViewAction::CutWordRight
| PlainTextEditorViewAction::SystemInsert
| PlainTextEditorViewAction::ExpandAlias
| PlainTextEditorViewAction::CycleCompletionSuggestion
)
}
}
#[cfg(test)]
#[path = "undo_test.rs"]
mod tests;
@@ -0,0 +1,305 @@
use super::{LocalUndoStack, UndoHistory};
use crate::editor::{
view::model::{
buffer::{
time::{Global, Lamport, LamportValue},
undo::UNDO_REDO_BATCH_TIMER,
ReplicaId,
},
Anchor, LocalSelection, LocalSelections,
},
PlainTextEditorViewAction,
};
use vec1::vec1;
fn local_selections(start: Anchor, end: Anchor) -> LocalSelections {
LocalSelections {
pending: None,
selections: vec1![LocalSelection::new_for_test(start, end)],
marked_text_state: Default::default(),
}
}
fn lamport(value: impl Into<LamportValue>) -> Lamport {
Lamport {
replica_id: ReplicaId::new(1),
value: value.into(),
}
}
#[test]
fn test_undo_stack_initialization() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
assert!(undo_stack.undo().is_none());
assert!(undo_stack.redo().is_none());
}
#[test]
fn test_undo_stack_undo_redo() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[0.into()].into_iter(),
local_selections(Anchor::Start, Anchor::End),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[1.into()].into_iter(),
local_selections(Anchor::End, Anchor::Start),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[2.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, [2.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, [1.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, [0.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
assert!(undo_stack.undo().is_none());
let (ops, selections) = undo_stack.redo().unwrap();
assert_eq!(ops, [0.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
let (ops, selections) = undo_stack.redo().unwrap();
assert_eq!(ops, [1.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
let (ops, selections) = undo_stack.redo().unwrap();
assert_eq!(ops, [2.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::End));
assert!(undo_stack.redo().is_none());
}
#[test]
fn test_undo_stack_selections_changed() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
undo_stack.record_selection_change(local_selections(Anchor::Start, Anchor::End));
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[0.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
undo_stack.record_selection_change(local_selections(Anchor::End, Anchor::Start));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![0.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
let (ops, selections) = undo_stack.redo().unwrap();
assert_eq!(ops, vec![0.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
}
#[test]
fn test_undo_stack_coalescing() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
// [`Action::InsertChar`] is not an atomic action so these edits should be coalesced.
undo_stack.record_edit(
PlainTextEditorViewAction::InsertChar,
[0.into()].into_iter(),
local_selections(Anchor::Start, Anchor::End),
);
undo_stack.record_edit(
PlainTextEditorViewAction::InsertChar,
[1.into()].into_iter(),
local_selections(Anchor::End, Anchor::Start),
);
undo_stack.record_edit(
PlainTextEditorViewAction::InsertChar,
[2.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![0.into(), 1.into(), 2.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
assert!(undo_stack.undo().is_none());
}
#[test]
fn test_undo_stack_expired_timer() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
// [`Action::InsertChar`] is not an atomic action so these edits should normally be coalesced
// but can't be in this case because the timer expires.
undo_stack.record_edit(
PlainTextEditorViewAction::InsertChar,
[0.into()].into_iter(),
local_selections(Anchor::Start, Anchor::End),
);
std::thread::sleep(UNDO_REDO_BATCH_TIMER + std::time::Duration::from_millis(10));
undo_stack.record_edit(
PlainTextEditorViewAction::InsertChar,
[1.into()].into_iter(),
local_selections(Anchor::End, Anchor::Start),
);
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![1.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![0.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::Start));
assert!(undo_stack.undo().is_none());
}
#[test]
fn test_undo_stack_undo_fork() {
let mut undo_stack = LocalUndoStack::new(local_selections(Anchor::Start, Anchor::Start));
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[0.into()].into_iter(),
local_selections(Anchor::Start, Anchor::End),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[1.into()].into_iter(),
local_selections(Anchor::End, Anchor::Start),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[2.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![2.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![1.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[4.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
assert!(undo_stack.redo().is_none());
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![4.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
let (ops, selections) = undo_stack.redo().unwrap();
assert_eq!(ops, vec![4.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::End));
assert!(undo_stack.redo().is_none());
}
#[test]
fn test_undo_stack_capacity() {
let mut undo_stack =
LocalUndoStack::new_with_capacity(local_selections(Anchor::Start, Anchor::Start), 3);
// [`Action::ReplaceBuffer`] is an atomic action so these edits won't be coalesced.
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[0.into()].into_iter(),
local_selections(Anchor::Start, Anchor::End),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[1.into()].into_iter(),
local_selections(Anchor::End, Anchor::Start),
);
undo_stack.record_edit(
PlainTextEditorViewAction::ReplaceBuffer,
[2.into()].into_iter(),
local_selections(Anchor::End, Anchor::End),
);
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![2.into()]);
assert_eq!(selections, local_selections(Anchor::End, Anchor::Start));
let (ops, selections) = undo_stack.undo().unwrap();
assert_eq!(ops, vec![1.into()]);
assert_eq!(selections, local_selections(Anchor::Start, Anchor::End));
// Since the capacity of the stack is 3, it can only keep track of the latest
// 2 edits (because it also tracks the base entry).
assert!(undo_stack.undo().is_none());
}
#[test]
fn test_max_undo_ts() {
let mut undo_history = UndoHistory::new();
undo_history.undo(lamport(1), 2.into());
undo_history.undo(lamport(0), 3.into());
undo_history.undo(lamport(1), 5.into());
undo_history.undo(lamport(0), 7.into());
assert_eq!(undo_history.max_undo_ts(&lamport(0)), 7.into());
assert_eq!(undo_history.max_undo_ts(&lamport(1)), 5.into());
assert_eq!(undo_history.max_undo_ts(&lamport(2)), 0.into());
}
#[test]
#[should_panic]
fn test_undo_history_grow_only() {
let mut undo_history = UndoHistory::new();
undo_history.undo(lamport(1), 3.into());
undo_history.undo(lamport(1), 2.into());
}
#[test]
fn test_is_edit_undone() {
let mut undo_history = UndoHistory::new();
undo_history.undo(lamport(1), 2.into());
undo_history.undo(lamport(0), 3.into());
undo_history.undo(lamport(1), 5.into());
assert!(undo_history.is_edit_undone(&lamport(0)));
assert!(!undo_history.is_edit_undone(&lamport(1)));
assert!(!undo_history.is_edit_undone(&lamport(2)));
}
#[test]
fn test_was_edit_undone() {
let mut undo_history = UndoHistory::new();
let mut versions = Global::new();
undo_history.undo(lamport(1), 2.into());
undo_history.undo(lamport(1), 3.into());
undo_history.undo(lamport(1), 5.into());
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
versions.observe(&lamport(2));
assert!(undo_history.was_edit_undone(&lamport(1), &versions));
versions.observe(&lamport(3));
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
versions.observe(&lamport(4));
assert!(!undo_history.was_edit_undone(&lamport(1), &versions));
versions.observe(&lamport(5));
assert!(undo_history.was_edit_undone(&lamport(1), &versions));
}
@@ -0,0 +1,513 @@
use super::super::buffer::{AnchorRangeExt, TextSummary};
use super::buffer::StylizedChar;
use super::{buffer, Anchor, Buffer, DisplayPoint, Edit, Point, ToCharOffset};
use crate::util::extensions::SliceExt as _;
use anyhow::{anyhow, Result};
use std::{
cmp::{self, Ordering},
iter::Take,
ops::Range,
};
use string_offset::CharOffset;
use sum_tree::{self, Cursor, Dimension, SeekBias, SumTree};
use warpui::text_layout::TextStyle;
use warpui::{AppContext, ModelHandle};
pub struct FoldMap {
buffer: ModelHandle<Buffer>,
transforms: SumTree<Transform>,
folds: Vec<Range<Anchor>>,
}
impl FoldMap {
pub fn new(buffer: ModelHandle<Buffer>, app: &AppContext) -> Self {
let text_summary = buffer.as_ref(app).text_summary();
Self {
buffer,
folds: Vec::new(),
transforms: SumTree::from_item(Transform {
summary: TransformSummary {
buffer: text_summary.clone(),
display: text_summary,
},
display_text: None,
}),
}
}
pub fn buffer_rows(&self, start_row: u32) -> Result<BufferRows<'_>> {
if start_row > self.transforms.summary().display.lines.row {
return Err(anyhow!("invalid display row {}", start_row));
}
let display_point = Point::new(start_row, 0);
let mut cursor = self.transforms.cursor();
cursor.seek(&DisplayPoint(display_point), SeekBias::Left);
Ok(BufferRows {
cursor,
display_point,
})
}
pub fn len(&self) -> CharOffset {
self.transforms.summary().display.chars
}
pub fn line_len(&self, row: u32, ctx: &AppContext) -> Result<u32> {
let line_start = self.to_display_offset(DisplayPoint::new(row, 0), ctx)?.0;
let line_end = if row >= self.max_point().row() {
self.len().as_usize()
} else {
self.to_display_offset(DisplayPoint::new(row + 1, 0), ctx)?
.0
- 1
};
Ok((line_end - line_start) as u32)
}
pub fn chars_with_style_at<'a>(
&'a self,
point: DisplayPoint,
app: &'a AppContext,
) -> Result<CharsWithStyle<'a>> {
let offset = self.to_display_offset(point, app)?;
let mut cursor = self.transforms.cursor();
cursor.seek(&offset, SeekBias::Right);
let buffer = self.buffer.as_ref(app);
Ok(CharsWithStyle {
cursor,
offset: CharOffset::from(offset.0),
buffer,
buffer_chars: None,
})
}
pub fn chars_at<'a>(&'a self, point: DisplayPoint, app: &'a AppContext) -> Result<Chars<'a>> {
Ok(Chars(self.chars_with_style_at(point, app)?))
}
pub fn max_point(&self) -> DisplayPoint {
DisplayPoint(self.transforms.summary().display.lines)
}
pub fn rightmost_point(&self) -> DisplayPoint {
DisplayPoint(self.transforms.summary().display.rightmost_point)
}
pub fn fold<T: ToCharOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
app: &AppContext,
) -> Result<()> {
let mut edits = Vec::new();
let buffer = self.buffer.as_ref(app);
for range in ranges.into_iter() {
let start = range.start.to_char_offset(buffer)?;
let end = range.end.to_char_offset(buffer)?;
edits.push(Edit {
old_range: start..end,
new_range: start..end,
});
let fold = buffer.anchor_after(start)?..buffer.anchor_before(end)?;
let ix = self
.folds
.find_insertion_index(|probe| probe.cmp(&fold, buffer))?;
self.folds.insert(ix, fold);
}
edits.sort_unstable_by(|a, b| {
a.old_range
.start
.cmp(&b.old_range.start)
.then_with(|| b.old_range.end.cmp(&a.old_range.end))
});
self.apply_edits(&edits, app)?;
Ok(())
}
pub fn unfold<T: ToCharOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
app: &AppContext,
) -> Result<()> {
let buffer = self.buffer.as_ref(app);
let mut edits = Vec::new();
for range in ranges.into_iter() {
let start = buffer.anchor_before(range.start.to_char_offset(buffer)?)?;
let end = buffer.anchor_after(range.end.to_char_offset(buffer)?)?;
// Remove intersecting folds and add their ranges to edits that are passed to apply_edits
self.folds.retain(|fold| {
if fold.start.cmp(&end, buffer).unwrap() > Ordering::Equal
|| fold.end.cmp(&start, buffer).unwrap() < Ordering::Equal
{
true
} else {
let start = fold.start.to_char_offset(buffer);
let end = fold.end.to_char_offset(buffer);
if let Ok((start, end)) = start.and_then(|start| Ok((start, end?))) {
let offset_range = start..end;
edits.push(Edit {
old_range: offset_range.clone(),
new_range: offset_range,
});
}
false
}
});
}
self.apply_edits(&edits, app)?;
Ok(())
}
pub fn is_line_folded(&self, display_row: u32) -> bool {
let mut cursor = self.transforms.cursor::<DisplayPoint, DisplayPoint>();
cursor.seek(&DisplayPoint::new(display_row, 0), SeekBias::Right);
while let Some(transform) = cursor.item() {
if transform.display_text.is_some() {
return true;
}
if cursor.end().row() == display_row {
cursor.next()
} else {
break;
}
}
false
}
pub fn to_display_offset(
&self,
point: DisplayPoint,
app: &AppContext,
) -> Result<DisplayOffset> {
let mut cursor = self.transforms.cursor::<DisplayPoint, TransformSummary>();
cursor.seek(&point, SeekBias::Right);
let overshoot = point.0 - cursor.start().display.lines;
let mut offset = cursor.start().display.chars;
if !overshoot.is_zero() {
let transform = cursor
.item()
.ok_or_else(|| anyhow!("display point {:?} is out of range", point))?;
assert!(transform.display_text.is_none());
let end_buffer_offset = (cursor.start().buffer.lines + overshoot)
.to_char_offset(self.buffer.as_ref(app))?;
offset += end_buffer_offset - cursor.start().buffer.chars;
}
Ok(offset.into())
}
pub fn to_buffer_point(&self, display_point: DisplayPoint) -> Point {
let mut cursor = self.transforms.cursor::<DisplayPoint, TransformSummary>();
cursor.seek(&display_point, SeekBias::Right);
let overshoot = display_point.0 - cursor.start().display.lines;
cursor.start().buffer.lines + overshoot
}
pub fn to_display_point(&self, point: Point) -> DisplayPoint {
let mut cursor = self.transforms.cursor::<Point, TransformSummary>();
cursor.seek(&point, SeekBias::Right);
let overshoot = point - cursor.start().buffer.lines;
DisplayPoint(cmp::min(
cursor.start().display.lines + overshoot,
cursor.end().display.lines,
))
}
pub fn apply_edits(&mut self, edits: &[Edit], app: &AppContext) -> Result<()> {
let buffer = self.buffer.as_ref(app);
let mut edits = edits.iter().cloned().peekable();
let mut new_transforms = SumTree::new();
let mut cursor = self.transforms.cursor::<CharOffset, CharOffset>();
cursor.seek(&0.into(), SeekBias::Right);
while let Some(mut edit) = edits.next() {
new_transforms.push_tree(cursor.slice(&edit.old_range.start, SeekBias::Left));
edit.new_range.start -= edit.old_range.start - *cursor.start();
edit.old_range.start = *cursor.start();
cursor.seek(&edit.old_range.end, SeekBias::Right);
cursor.next();
let mut delta = edit.delta();
loop {
edit.old_range.end = *cursor.start();
if let Some(next_edit) = edits.peek() {
if next_edit.old_range.start > edit.old_range.end {
break;
}
let next_edit = edits.next().unwrap();
delta += next_edit.delta();
if next_edit.old_range.end > edit.old_range.end {
edit.old_range.end = next_edit.old_range.end;
cursor.seek(&edit.old_range.end, SeekBias::Right);
cursor.next();
}
} else {
break;
}
}
edit.new_range.end = CharOffset::from(
((edit.new_range.start + edit.old_extent()).as_usize() as isize + delta) as usize,
);
let anchor = buffer.anchor_before(edit.new_range.start)?;
let folds_start = self
.folds
.find_insertion_index(|probe| probe.start.cmp(&anchor, buffer))?;
let mut folds = self.folds[folds_start..]
.iter()
.filter_map(|fold| {
Some(
fold.start.to_char_offset(buffer).ok()?
..fold.end.to_char_offset(buffer).ok()?,
)
})
.peekable();
while folds
.peek()
.is_some_and(|fold| fold.start < edit.new_range.end)
{
let mut fold = folds.next().unwrap();
let sum = new_transforms.summary();
assert!(fold.start >= sum.buffer.chars);
while folds
.peek()
.is_some_and(|next_fold| next_fold.start <= fold.end)
{
let next_fold = folds.next().unwrap();
if next_fold.end > fold.end {
fold.end = next_fold.end;
}
}
if fold.start > sum.buffer.chars {
let text_summary = buffer.text_summary_for_range(sum.buffer.chars..fold.start);
new_transforms.push(Transform {
summary: TransformSummary {
display: text_summary.clone(),
buffer: text_summary,
},
display_text: None,
});
}
if fold.end > fold.start {
new_transforms.push(Transform {
summary: TransformSummary {
display: TextSummary {
chars: 1.into(),
bytes: ''.len_utf8().into(),
lines: Point::new(0, 1),
first_line_len: 1,
rightmost_point: Point::new(0, 1),
},
buffer: buffer.text_summary_for_range(fold.start..fold.end),
},
display_text: Some('…'),
});
}
}
let sum = new_transforms.summary();
if sum.buffer.chars < edit.new_range.end {
let text_summary =
buffer.text_summary_for_range(sum.buffer.chars..edit.new_range.end);
new_transforms.push(Transform {
summary: TransformSummary {
display: text_summary.clone(),
buffer: text_summary,
},
display_text: None,
});
}
}
new_transforms.push_tree(cursor.suffix());
drop(cursor);
self.transforms = new_transforms;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct Transform {
summary: TransformSummary,
display_text: Option<char>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TransformSummary {
display: TextSummary,
buffer: TextSummary,
}
impl sum_tree::Item for Transform {
type Summary = TransformSummary;
fn summary(&self) -> Self::Summary {
self.summary.clone()
}
}
impl<'a> std::ops::AddAssign<&'a Self> for TransformSummary {
fn add_assign(&mut self, other: &'a Self) {
self.buffer += &other.buffer;
self.display += &other.display;
}
}
impl<'a> Dimension<'a, TransformSummary> for TransformSummary {
fn add_summary(&mut self, summary: &'a TransformSummary) {
*self += summary;
}
}
pub struct BufferRows<'a> {
cursor: Cursor<'a, Transform, DisplayPoint, TransformSummary>,
display_point: Point,
}
impl Iterator for BufferRows<'_> {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
while self.display_point > self.cursor.end().display.lines {
self.cursor.next();
if self.cursor.item().is_none() {
// TODO: Return a bool from next?
break;
}
}
if self.cursor.item().is_some() {
let overshoot = self.display_point - self.cursor.start().display.lines;
let buffer_point = self.cursor.start().buffer.lines + overshoot;
self.display_point.row += 1;
Some(buffer_point.row)
} else {
None
}
}
}
pub struct Chars<'a>(CharsWithStyle<'a>);
impl Iterator for Chars<'_> {
type Item = char;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(Into::into)
}
}
pub struct CharsWithStyle<'a> {
cursor: Cursor<'a, Transform, DisplayOffset, TransformSummary>,
offset: CharOffset,
buffer: &'a Buffer,
buffer_chars: Option<Take<buffer::CharsWithStyle<'a>>>,
}
impl Iterator for CharsWithStyle<'_> {
type Item = StylizedChar;
fn next(&mut self) -> Option<Self::Item> {
match self.buffer_chars.as_mut().map(Iterator::next) {
// buffer_chars is set and has a value, return it
Some(Some(c)) => {
self.offset += 1;
return Some(c);
}
// buffer_chars is set, but exhausted, so the current cursor item is complete
Some(None) => {
self.buffer_chars = None;
self.cursor.next();
}
// buffer_chars is not set, if we've returned all of the current cursor item's
// characters, then we should move to the next value
None => {
if self.offset == self.cursor.end().display.chars {
self.cursor.next();
}
}
}
self.cursor.item().and_then(|transform| {
if let Some(c) = transform.display_text {
self.offset += 1;
Some(StylizedChar::new(c, TextStyle::default()))
} else {
let overshoot = self.offset - self.cursor.start().display.chars;
let buffer_start = self.cursor.start().buffer.chars + overshoot;
let char_count = self.cursor.end().buffer.chars - buffer_start;
self.buffer_chars = Some(
self.buffer
.stylized_chars_at(buffer_start)
.unwrap()
.take(char_count.as_usize()),
);
self.next()
}
})
}
}
impl<'a> Dimension<'a, TransformSummary> for DisplayPoint {
fn add_summary(&mut self, summary: &'a TransformSummary) {
self.0 += summary.display.lines;
}
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
/// The number of _visible_ characters offset from the start of the buffer.
pub struct DisplayOffset(usize);
impl From<usize> for DisplayOffset {
fn from(usize: usize) -> Self {
DisplayOffset(usize)
}
}
impl From<CharOffset> for DisplayOffset {
fn from(char_offset: CharOffset) -> Self {
DisplayOffset(char_offset.as_usize())
}
}
impl<'a> Dimension<'a, TransformSummary> for DisplayOffset {
fn add_summary(&mut self, summary: &'a TransformSummary) {
self.0 += &summary.display.chars.as_usize();
}
}
impl<'a> Dimension<'a, TransformSummary> for Point {
fn add_summary(&mut self, summary: &'a TransformSummary) {
*self += summary.buffer.lines;
}
}
impl<'a> Dimension<'a, TransformSummary> for CharOffset {
fn add_summary(&mut self, summary: &'a TransformSummary) {
*self += summary.buffer.chars;
}
}
#[cfg(test)]
#[path = "fold_map_test.rs"]
mod tests;
@@ -0,0 +1,295 @@
use super::*;
use crate::editor::tests::{sample_text, RandomCharIter};
use crate::editor::EditOrigin;
use tests::buffer::RangesWhenEditing;
use warpui::App;
#[test]
fn test_basic_folds() -> Result<()> {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
app.read(|app| {
map.fold(
vec![
Point::new(0, 2)..Point::new(2, 2),
Point::new(2, 4)..Point::new(4, 1),
],
app,
)?;
assert_eq!(map.text(app), "aa…cc…eeeee");
Ok::<(), anyhow::Error>(())
})?;
let edits = buffer.update(&mut app, |buffer, ctx| {
let start_version = buffer.versions();
buffer.edit_for_test(
vec![
Point::new(0, 0)..Point::new(0, 1),
Point::new(2, 3)..Point::new(2, 3),
],
"123",
EditOrigin::UserInitiated,
ctx,
)?;
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
})?;
app.read(|app| {
map.apply_edits(&edits, app)?;
assert_eq!(map.text(app), "123a…c123c…eeeee");
Ok::<(), anyhow::Error>(())
})?;
let edits = buffer.update(&mut app, |buffer, ctx| {
let start_version = buffer.versions();
buffer.edit_for_test(
Some(Point::new(2, 6)..Point::new(4, 3)),
"456",
EditOrigin::UserInitiated,
ctx,
)?;
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
})?;
app.read(|app| {
map.apply_edits(&edits, app)?;
assert_eq!(map.text(app), "123a…c123456eee");
map.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), app)?;
assert_eq!(map.text(app), "123aaaaa\nbbbbbb\nccc123456eee");
Ok(())
})
})
}
#[test]
fn test_overlapping_folds() -> Result<()> {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
app.read(|app| {
let mut map = FoldMap::new(buffer.clone(), app);
map.fold(
vec![
Point::new(0, 2)..Point::new(2, 2),
Point::new(0, 4)..Point::new(1, 0),
Point::new(1, 2)..Point::new(3, 2),
Point::new(3, 1)..Point::new(4, 1),
],
app,
)?;
assert_eq!(map.text(app), "aa…eeeee");
Ok(())
})
})
}
#[test]
fn test_merging_folds_via_edit() -> Result<()> {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(sample_text(5, 6)));
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
app.read(|app| {
map.fold(
vec![
Point::new(0, 2)..Point::new(2, 2),
Point::new(3, 1)..Point::new(4, 1),
],
app,
)?;
assert_eq!(map.text(app), "aa…cccc\nd…eeeee");
Ok::<(), anyhow::Error>(())
})?;
let edits = buffer.update(&mut app, |buffer, ctx| {
let start_version = buffer.versions();
buffer.edit_for_test(
Some(Point::new(2, 2)..Point::new(3, 1)),
"",
EditOrigin::UserInitiated,
ctx,
)?;
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
})?;
app.read(|app| {
map.apply_edits(&edits, app)?;
assert_eq!(map.text(app), "aa…eeeee");
Ok(())
})
})
}
#[test]
fn test_random_folds() -> Result<()> {
use super::super::buffer::ToPoint;
use rand::prelude::*;
for seed in 0..100 {
println!("{seed:?}");
let mut rng = StdRng::seed_from_u64(seed);
App::test((), |mut app| async move {
let buffer = app.add_model(|_| {
let len = rng.gen_range(0..10);
let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
Buffer::new(text)
});
let mut map = app.read(|app| FoldMap::new(buffer.clone(), app));
app.read(|app| -> Result<()> {
let buffer = buffer.as_ref(app);
let fold_count = rng.gen_range(0..10);
let mut fold_ranges: Vec<Range<CharOffset>> = Vec::new();
for _ in 0..fold_count {
let end = rng.gen_range(0..buffer.len().as_usize() + 1);
let start = rng.gen_range(0..end + 1);
fold_ranges.push(CharOffset::from(start)..CharOffset::from(end));
}
map.fold(fold_ranges, app)?;
let mut expected_text = buffer.text();
for fold_range in map.merged_fold_ranges(app).into_iter().rev() {
expected_text
.replace_range(fold_range.start.as_usize()..fold_range.end.as_usize(), "");
}
assert_eq!(map.text(app), expected_text);
for fold_range in map.merged_fold_ranges(app) {
let display_point =
map.to_display_point(fold_range.start.to_point(buffer).unwrap());
assert!(map.is_line_folded(display_point.row()));
}
Ok::<(), anyhow::Error>(())
})?;
let edits = buffer.update(&mut app, |buffer, ctx| {
let start_version = buffer.versions();
let edit_count = rng.gen_range(1..10);
buffer.randomly_edit(
&mut rng,
RangesWhenEditing::UseRandomRanges {
num_ranges: edit_count,
},
ctx,
);
Ok::<_, anyhow::Error>(buffer.edits_since(start_version).collect::<Vec<_>>())
})?;
app.read(|app| {
map.apply_edits(&edits, app)?;
let buffer = map.buffer.as_ref(app);
let mut expected_text = buffer.text();
for fold_range in map.merged_fold_ranges(app).into_iter().rev() {
expected_text
.replace_range(fold_range.start.as_usize()..fold_range.end.as_usize(), "");
}
assert_eq!(map.text(app), expected_text);
Ok::<(), anyhow::Error>(())
})?;
Ok::<(), anyhow::Error>(())
})?;
}
Ok(())
}
#[test]
fn test_buffer_rows() -> Result<()> {
App::test((), |mut app| async move {
let text = sample_text(6, 6) + "\n";
let buffer = app.add_model(|_| Buffer::new(text));
app.read(|app| {
let mut map = FoldMap::new(buffer.clone(), app);
map.fold(
vec![
Point::new(0, 2)..Point::new(2, 2),
Point::new(3, 1)..Point::new(4, 1),
],
app,
)?;
assert_eq!(map.text(app), "aa…cccc\nd…eeeee\nffffff\n");
assert_eq!(map.buffer_rows(0)?.collect::<Vec<_>>(), vec![0, 3, 5, 6]);
assert_eq!(map.buffer_rows(3)?.collect::<Vec<_>>(), vec![6]);
Ok(())
})
})
}
#[test]
fn test_desynced_buffer() {
App::test((), |mut app| async move {
// Create a buffer with 20 characters
let buffer = app.add_model(|_| Buffer::new(sample_text(1, 20)));
app.update(|app| {
let map = FoldMap::new(buffer.clone(), app);
buffer.update(app, |buf, ctx| {
// Update the buffer to only have 10 characters
buf.edit_for_test(
Some(0..20),
sample_text(1, 10),
EditOrigin::UserInitiated,
ctx,
)
.unwrap();
});
let mut chars = map.chars_at(DisplayPoint::new(0, 10), app).unwrap();
assert_eq!(
chars.next(),
None,
"Shouldn't attempt to return unavailable characters"
);
});
})
}
impl FoldMap {
fn text(&self, app: &AppContext) -> String {
self.chars_at(DisplayPoint(Point::zero()), app)
.unwrap()
.collect()
}
fn merged_fold_ranges(&self, app: &AppContext) -> Vec<Range<CharOffset>> {
let buffer = self.buffer.as_ref(app);
let mut fold_ranges = self
.folds
.iter()
.map(|fold| {
fold.start.to_char_offset(buffer).unwrap()..fold.end.to_char_offset(buffer).unwrap()
})
.peekable();
let mut merged_ranges = Vec::new();
while let Some(mut fold_range) = fold_ranges.next() {
while let Some(next_range) = fold_ranges.peek() {
if fold_range.end >= next_range.start {
if next_range.end > fold_range.end {
fold_range.end = next_range.end;
}
fold_ranges.next();
} else {
break;
}
}
if fold_range.end > fold_range.start {
merged_ranges.push(fold_range);
}
}
merged_ranges
}
}
@@ -0,0 +1,516 @@
mod fold_map;
use super::buffer::{self, Anchor, Buffer, Edit, StylizedChar, ToCharOffset, ToPoint};
use crate::editor::soft_wrap::{self, DisplayPointAndClampDirection, SoftWrapPoint, SoftWrapState};
use anyhow::{Context, Result};
pub use fold_map::BufferRows;
use fold_map::FoldMap;
use std::cmp;
use std::ops::Range;
use string_offset::CharOffset;
use warpui::text::point::Point;
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
#[derive(Copy, Clone)]
pub enum Bias {
Left,
Right,
}
pub struct DisplayMap {
buffer: ModelHandle<Buffer>,
fold_map: FoldMap,
soft_wrap_state: SoftWrapState,
tab_size: usize,
}
pub struct MovementResult {
pub point_and_clamp_direction: DisplayPointAndClampDirection,
/// True if the resulting point is the same row as the original row.
pub is_same_row: bool,
/// The desired column based on the original point. This could be higher than
/// the returned column in point because the current row may not have had as
/// many columns as the previous row.
pub goal_column: u32,
}
pub enum Event {
Folded,
Unfolded,
}
impl Entity for DisplayMap {
type Event = Event;
}
impl DisplayMap {
fn new_internal(
buffer: ModelHandle<Buffer>,
subscribe_to_buffer: bool,
tab_size: usize,
ctx: &mut ModelContext<Self>,
) -> Self {
if subscribe_to_buffer {
ctx.subscribe_to_model(&buffer, Self::handle_buffer_event);
}
DisplayMap {
buffer: buffer.clone(),
fold_map: FoldMap::new(buffer, ctx),
soft_wrap_state: Default::default(),
tab_size,
}
}
pub fn new(buffer: ModelHandle<Buffer>, tab_size: usize, ctx: &mut ModelContext<Self>) -> Self {
Self::new_internal(buffer, true, tab_size, ctx)
}
pub fn recreate(&mut self, tab_size: usize, ctx: &mut ModelContext<Self>) {
*self = Self::new_internal(self.buffer.clone(), false, tab_size, ctx);
}
pub fn buffer<'a>(&self, app: &'a AppContext) -> &'a Buffer {
self.buffer.as_ref(app)
}
pub fn soft_wrap_state(&self) -> SoftWrapState {
self.soft_wrap_state.clone()
}
pub fn fold<T: ToCharOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
ctx: &mut ModelContext<Self>,
) -> Result<()> {
self.fold_map.fold(ranges, ctx)?;
ctx.emit(Event::Folded);
Ok(())
}
pub fn unfold<T: ToCharOffset>(
&mut self,
ranges: impl IntoIterator<Item = Range<T>>,
ctx: &mut ModelContext<Self>,
) -> Result<()> {
self.fold_map.unfold(ranges, ctx)?;
ctx.emit(Event::Unfolded);
Ok(())
}
/// TODO(zheng) Consolidate logic with `down`.
pub fn up(
&self,
point: DisplayPoint,
goal_column: Option<u32>,
clamp_direction: soft_wrap::ClampDirection,
) -> Result<MovementResult> {
self.soft_wrap_state.read(|frame_layouts| {
let frame_layouts = frame_layouts.map_err(|err| anyhow::anyhow!("Could not read Frame Layouts {:?}", err))?;
let point = frame_layouts.to_soft_wrap_point(point, clamp_direction).context("Point is out of bounds of laid out text")?;
let line = frame_layouts
.get_line(point.row() as usize)
.context("Should have current line as it should not be possible for it to be beyond bounds, but was not able to retrieve it")?;
// Note that the index here will correspond to that of the original text so if
// this line is soft wrapped, it will be higher than 0.
let line_first_index = line.first_glyph().map_or(0, |glyph| glyph.index);
let relative_index = point.column() - line_first_index as u32;
// Note that, as also mentioned in the rustdoc of this function,
// the goal column is a relative index to the first point of the row
// so it starts at 0 and is at most the length of a row (but it could
// be higher than the current row as it is preserved across rows).
let goal_column = match goal_column {
Some(goal_column) => cmp::max(goal_column, relative_index),
None => relative_index,
};
let (point, is_same_row) = if point.row() > 0 {
let prev_line = frame_layouts
.get_line(point.row() as usize - 1)
.context("Should have next line based on max point bounds check, but was not able to retrieve it")?;
let prev_line_first_column = prev_line.first_glyph().map_or(0, |glyph| glyph.index as u32);
// Note that goal column is a relative value, rather than based
// on the original string.
let new_column = prev_line_first_column + goal_column;
let last_index_in_line = prev_line.last_glyph().map(|glyph| glyph.index as u32 + 1)
.unwrap_or(0);
(SoftWrapPoint::new(
point.row() - 1,
cmp::min(new_column, last_index_in_line),
), false)
} else {
(SoftWrapPoint::new(0, 0), true)
};
Ok(MovementResult {
point_and_clamp_direction: frame_layouts.to_display_point(point),
goal_column,
is_same_row,
})
})
}
/// Given a `DisplayPoint` and the goal column (i.e. the rightmost column
/// that we _want_ to be at which could be higher than the current point's
/// column) returns the point and new goal column we would be at if we were
/// to navigate one row down from the point with soft wrap in consideration.
///
/// Note that while `goal_column` is the column number of the row, the column
/// numbers in `SoftWrapPoint`, are relative to the original string.
/// TODO(zheng) Consolidate logic with `up`.
pub fn down(
&self,
point: DisplayPoint,
goal_column: Option<u32>,
clamp_direction: soft_wrap::ClampDirection,
) -> Result<MovementResult> {
self.soft_wrap_state.read(|frame_layouts| {
let frame_layouts = frame_layouts.map_err(|err| anyhow::anyhow!("Could not read Frame Layouts {:?}", err))?;
let point = frame_layouts.to_soft_wrap_point(point, clamp_direction).ok_or_else(|| anyhow::anyhow!("Point is out of bounds of laid out text"))?;
let line = frame_layouts
.get_line(point.row() as usize)
.context("Should have current line as it should not be possible for it to be beyond bounds, but was not able to retrieve it")?;
// Note that the index here will correspond to that of the original text so if
// this line is soft wrapped, it will be higher than 0.
let line_first_index = line.first_glyph().map_or(0, |glyph| glyph.index);
let relative_index = point.column() - line_first_index as u32;
// Note that, as also mentioned in the rustdoc of this function,
// the goal column is a relative index to the first point of the row
// so it starts at 0 and is at most the length of a row (but it could
// be higher than the current row as it is preserved across rows).
let goal_column = match goal_column {
Some(goal_column) => cmp::max(goal_column, relative_index),
None => relative_index,
};
let max_row = frame_layouts.num_lines() - 1;
let max_col = frame_layouts
.get_line(max_row)
.and_then(|line| line.last_glyph().map(|glyph| glyph.index + 1))
.unwrap_or(0);
let max_point = SoftWrapPoint::new(max_row as u32, max_col as u32);
let (point, is_same_row) = if point.row() < max_point.row() {
let next_line = frame_layouts
.get_line(point.row() as usize + 1)
.context("Should have next line based on max point bounds check, but was not able to retrieve it")?;
let next_line_first_column = next_line.first_glyph().map_or(0, |glyph| glyph.index as u32);
// Note that goal column is a relative value, rather than based
// on the original string.
let new_column = next_line_first_column + goal_column;
let last_index_in_line = next_line.last_glyph().map(|glyph| glyph.index as u32 + 1)
.unwrap_or(0);
(SoftWrapPoint::new(
point.row() + 1,
cmp::min(new_column, last_index_in_line),
), false)
} else {
(max_point, true)
};
Ok(MovementResult {
point_and_clamp_direction: frame_layouts.to_display_point(point),
goal_column,
is_same_row,
})
})
}
pub fn to_soft_wrap_point(
&self,
point: DisplayPoint,
clamp_direction: soft_wrap::ClampDirection,
) -> Option<SoftWrapPoint> {
self.soft_wrap_state
.read(|frame_layouts| match frame_layouts {
Ok(frame_layouts) => frame_layouts.to_soft_wrap_point(point, clamp_direction),
Err(err) => {
log::warn!("Error attempting to get soft wrap point {err:?}");
None
}
})
}
pub fn is_line_folded(&self, display_row: u32) -> bool {
self.fold_map.is_line_folded(display_row)
}
pub fn tab_size(&self) -> usize {
self.tab_size
}
#[cfg(test)]
pub fn text(&self, app: &AppContext) -> String {
self.chars_at(DisplayPoint::zero(), app).unwrap().collect()
}
pub fn line(&self, display_row: u32, app: &AppContext) -> Result<String> {
let chars = self.chars_at(DisplayPoint::new(display_row, 0), app)?;
Ok(chars.take_while(|c| *c != '\n').collect())
}
pub fn chars_with_styles_at<'a>(
&'a self,
point: DisplayPoint,
app: &'a AppContext,
) -> Result<impl 'a + Iterator<Item = StylizedChar>> {
let column = point.column() as usize;
let (point, to_next_stop) = point.collapse_tabs(self, Bias::Left, app)?;
let mut fold_chars = self.fold_map.chars_with_style_at(point, app)?;
if to_next_stop > 0 {
fold_chars.next();
}
Ok(CharsWithStyles {
fold_chars,
column,
to_next_stop,
tab_size: self.tab_size,
})
}
pub fn chars_at<'a>(
&'a self,
point: DisplayPoint,
app: &'a AppContext,
) -> Result<impl 'a + Iterator<Item = char>> {
Ok(self.chars_with_styles_at(point, app)?.map(Into::into))
}
pub fn buffer_rows(&self, start_row: u32) -> Result<BufferRows<'_>> {
self.fold_map.buffer_rows(start_row)
}
pub fn line_len(&self, row: u32, ctx: &AppContext) -> Result<u32> {
DisplayPoint::new(row, self.fold_map.line_len(row, ctx)?)
.expand_tabs(self, ctx)
.map(|point| point.column())
}
pub fn max_point(&self, app: &AppContext) -> DisplayPoint {
self.fold_map.max_point().expand_tabs(self, app).unwrap()
}
pub fn rightmost_point(&self) -> DisplayPoint {
self.fold_map.rightmost_point()
}
pub fn anchor_before(
&self,
point: DisplayPoint,
bias: Bias,
app: &AppContext,
) -> Result<Anchor> {
self.buffer
.as_ref(app)
.anchor_before(point.to_buffer_point(self, bias, app)?)
}
#[allow(dead_code)]
pub fn anchor_after(
&self,
point: DisplayPoint,
bias: Bias,
app: &AppContext,
) -> Result<Anchor> {
self.buffer
.as_ref(app)
.anchor_after(point.to_buffer_point(self, bias, app)?)
}
pub fn apply_edits(&mut self, edits: &[Edit], ctx: &AppContext) -> Result<()> {
self.fold_map.apply_edits(edits, ctx)
}
fn handle_buffer_event(&mut self, event: &buffer::Event, ctx: &mut ModelContext<Self>) {
match event {
buffer::Event::Edited { edits, .. } => self.apply_edits(edits, ctx).unwrap(),
buffer::Event::StylesUpdated
| buffer::Event::UpdatePeers { .. }
| buffer::Event::SelectionsChanged => {}
}
}
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct DisplayPoint(Point);
impl DisplayPoint {
pub fn new(row: u32, column: u32) -> Self {
Self(Point::new(row, column))
}
#[allow(dead_code)]
pub fn zero() -> Self {
Self::new(0, 0)
}
pub fn row(self) -> u32 {
self.0.row
}
pub fn column(self) -> u32 {
self.0.column
}
pub fn row_mut(&mut self) -> &mut u32 {
&mut self.0.row
}
pub fn column_mut(&mut self) -> &mut u32 {
&mut self.0.column
}
pub fn to_buffer_point(self, map: &DisplayMap, bias: Bias, app: &AppContext) -> Result<Point> {
Ok(map
.fold_map
.to_buffer_point(self.collapse_tabs(map, bias, app)?.0))
}
pub fn to_char_offset(
self,
map: &DisplayMap,
bias: Bias,
buffer: &Buffer,
app: &AppContext,
) -> Result<CharOffset> {
let point = self.to_buffer_point(map, bias, app)?;
point.to_char_offset(buffer)
}
fn expand_tabs(mut self, map: &DisplayMap, app: &AppContext) -> Result<Self> {
let chars = map
.fold_map
.chars_at(DisplayPoint(Point::new(self.row(), 0)), app)?;
let expanded = expand_tabs(chars, self.column() as usize, map.tab_size);
*self.column_mut() = expanded as u32;
Ok(self)
}
fn collapse_tabs(
mut self,
map: &DisplayMap,
bias: Bias,
app: &AppContext,
) -> Result<(Self, usize)> {
let chars = map
.fold_map
.chars_at(DisplayPoint(Point::new(self.0.row, 0)), app)?;
let expanded = self.column() as usize;
let (collapsed, to_next_stop) = collapse_tabs(chars, expanded, bias, map.tab_size);
*self.column_mut() = collapsed as u32;
Ok((self, to_next_stop))
}
}
/// Trait for types that can map onto a display point, accounting for soft-wrapping
/// and text folding.
pub trait ToDisplayPoint {
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint>;
}
impl ToDisplayPoint for Point {
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint> {
let mut display_point = map.fold_map.to_display_point(self);
let chars = map
.fold_map
.chars_at(DisplayPoint::new(display_point.row(), 0), app)?;
*display_point.column_mut() =
expand_tabs(chars, display_point.column() as usize, map.tab_size) as u32;
Ok(display_point)
}
}
impl ToDisplayPoint for &Anchor {
fn to_display_point(self, map: &DisplayMap, app: &AppContext) -> Result<DisplayPoint> {
self.to_point(map.buffer.as_ref(app))?
.to_display_point(map, app)
}
}
pub struct CharsWithStyles<'a> {
fold_chars: fold_map::CharsWithStyle<'a>,
column: usize,
to_next_stop: usize,
tab_size: usize,
}
impl Iterator for CharsWithStyles<'_> {
type Item = StylizedChar;
fn next(&mut self) -> Option<Self::Item> {
if self.to_next_stop > 0 {
self.to_next_stop -= 1;
self.column += 1;
Some(StylizedChar::new(' ', Default::default()))
} else {
self.fold_chars.next().map(|c| match c.char() {
'\t' => {
self.to_next_stop = self.tab_size - self.column % self.tab_size - 1;
self.column += 1;
StylizedChar::new(' ', c.style())
}
'\n' => {
self.column = 0;
c
}
_ => {
self.column += 1;
c
}
})
}
}
}
pub fn expand_tabs(chars: impl Iterator<Item = char>, column: usize, tab_size: usize) -> usize {
let mut expanded = 0;
for c in chars.take(column) {
if c == '\t' {
expanded += tab_size - expanded % tab_size;
} else {
expanded += 1;
}
}
expanded
}
pub fn collapse_tabs(
chars: impl Iterator<Item = char>,
column: usize,
bias: Bias,
tab_size: usize,
) -> (usize, usize) {
let mut expanded = 0;
let mut collapsed = 0;
for c in chars {
if expanded == column {
break;
}
if c == '\t' {
expanded += tab_size - (expanded % tab_size);
if expanded > column {
return match bias {
Bias::Left => (collapsed, expanded - column),
Bias::Right => (collapsed + 1, 0),
};
}
} else {
expanded += 1;
}
collapsed += 1;
}
(collapsed, 0)
}
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;
@@ -0,0 +1,87 @@
use crate::editor::tests::sample_text;
use super::*;
use crate::editor::EditOrigin;
use anyhow::Error;
use warpui::App;
#[test]
fn test_chars_at() -> Result<()> {
App::test((), |mut app| async move {
let text = sample_text(6, 6);
let buffer = app.add_model(|_| Buffer::new(text));
let map = app.add_model(|ctx| DisplayMap::new(buffer.clone(), 4, ctx));
buffer.update(&mut app, |buffer, ctx| {
buffer.edit_for_test(
vec![
Point::new(1, 0)..Point::new(1, 0),
Point::new(1, 1)..Point::new(1, 1),
Point::new(2, 1)..Point::new(2, 1),
],
"\t",
EditOrigin::UserInitiated,
ctx,
)
})?;
map.read(&app, |map, ctx| {
assert_eq!(
map.chars_at(DisplayPoint::new(1, 0), ctx)?
.take(10)
.collect::<String>(),
" b bb"
);
assert_eq!(
map.chars_at(DisplayPoint::new(1, 2), ctx)?
.take(10)
.collect::<String>(),
" b bbbb"
);
assert_eq!(
map.chars_at(DisplayPoint::new(1, 6), ctx)?
.take(13)
.collect::<String>(),
" bbbbb\nc c"
);
Ok::<(), Error>(())
})?;
Ok(())
})
}
#[test]
fn test_expand_tabs() {
assert_eq!(expand_tabs("\t".chars(), 0, 4), 0);
assert_eq!(expand_tabs("\t".chars(), 1, 4), 4);
assert_eq!(expand_tabs("\ta".chars(), 2, 4), 5);
}
#[test]
fn test_collapse_tabs() {
assert_eq!(collapse_tabs("\t".chars(), 0, Bias::Left, 4), (0, 0));
assert_eq!(collapse_tabs("\t".chars(), 0, Bias::Right, 4), (0, 0));
assert_eq!(collapse_tabs("\t".chars(), 1, Bias::Left, 4), (0, 3));
assert_eq!(collapse_tabs("\t".chars(), 1, Bias::Right, 4), (1, 0));
assert_eq!(collapse_tabs("\t".chars(), 2, Bias::Left, 4), (0, 2));
assert_eq!(collapse_tabs("\t".chars(), 2, Bias::Right, 4), (1, 0));
assert_eq!(collapse_tabs("\t".chars(), 3, Bias::Left, 4), (0, 1));
assert_eq!(collapse_tabs("\t".chars(), 3, Bias::Right, 4), (1, 0));
assert_eq!(collapse_tabs("\t".chars(), 4, Bias::Left, 4), (1, 0));
assert_eq!(collapse_tabs("\t".chars(), 4, Bias::Right, 4), (1, 0));
assert_eq!(collapse_tabs("\ta".chars(), 5, Bias::Left, 4), (2, 0));
assert_eq!(collapse_tabs("\ta".chars(), 5, Bias::Right, 4), (2, 0));
}
#[test]
fn test_max_point() -> Result<()> {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new("aaa\n\t\tbbb"));
let map = app.add_model(|ctx| DisplayMap::new(buffer.clone(), 4, ctx));
map.read(&app, |map, app| {
assert_eq!(map.max_point(app), DisplayPoint::new(1, 11))
});
Ok(())
})
}
File diff suppressed because it is too large Load Diff
+723
View File
@@ -0,0 +1,723 @@
use string_offset::{ByteOffset, CharOffset};
use warpui::{text_layout::TextStyle, App};
use crate::editor::{EditorSnapshot, PlainTextEditorViewAction, TextRun, ValidInputType};
use super::{EditOrigin, EditorModel, Edits, InteractionState, UpdateBufferOption};
use vec1::vec1;
#[test]
#[should_panic]
fn test_change_buffer_without_edit() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
// This should panic because we didn't edit the buffer via [`EditorModel::edit`].
model.update(&mut app, |model, ctx| model.insert("hello", None, ctx))
})
}
#[test]
fn test_edit_change_selections() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("abc".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_change_selections(|model, ctx| {
model.cursor_line_end(/* keep_selection */ false, ctx);
}),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(3)..ByteOffset::from(3)
);
});
})
}
#[test]
fn test_edit_update_buffer() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), String::from("hello"));
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("world", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
});
// `edit` ensures we add stuff to the undo stack correctly, so undo'ing
// should bring us back to "hello".
model.update(&mut app, |model, ctx| {
model.undo(ctx);
assert_eq!(model.buffer_text(ctx), String::from("hello"));
});
})
}
#[test]
fn test_edit_update_buffer_ignoring_undo() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(model.last_action(ctx).is_none());
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::SkipUndoRedoRecord,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::SkipUndoRedoRecord,
|model, ctx| model.insert("world", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
});
// Since we edited and ignored undo, undo should be a no-op.
model.update(&mut app, |model, ctx| {
model.undo(ctx);
assert_eq!(model.buffer_text(ctx), "helloworld".to_string());
});
})
}
#[test]
fn test_edit_post_buffer_edit_change_selections() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new()
.with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("hello", None, ctx),
)
.with_post_buffer_edit_change_selections(|buffer, ctx| {
buffer.move_to_buffer_end(false, ctx);
}),
)
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), String::from("hello"));
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(5)..ByteOffset::from(5)
);
});
})
}
#[test]
fn test_edit_merge_selections() {
App::test((), |mut app| async move {
let model = app
.add_model(|ctx| EditorModel::new("hello".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "hello".to_string());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
// Duplicate the first selection (the cursor).
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_change_selections(|model, ctx| {
let first_selection = model.first_selection(ctx).to_owned();
model.change_selections(vec1![first_selection.clone(), first_selection], ctx);
}),
);
});
// `edit` should merge selections and de-dupe.
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "hello".to_string());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
// Add a cursor before h and after h, and then backspace.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new()
.with_change_selections(|model, ctx| {
model
.select_ranges_by_offset(
[
CharOffset::from(0)..CharOffset::from(0),
CharOffset::from(1)..CharOffset::from(1),
],
ctx,
)
.expect("can select ranges by offset")
})
.with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.backspace(ctx),
),
)
});
// The cursors should collapse into one.
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "ello".to_string());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
})
}
#[test]
fn test_edit_when_interaction_disabled() {
App::test((), |mut app| async move {
let model = app.add_model(|ctx| {
let mut model = EditorModel::new("".into(), 0, None, ValidInputType::All, ctx);
model.set_interaction_state(InteractionState::Disabled);
model
});
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new()
.with_change_selections(|model, ctx| {
model.cursor_line_end(/* keep_selection */ false, ctx);
})
.with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
// Neither the text nor selection state should have changed.
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
})
}
#[test]
fn test_edit_when_only_selectable() {
App::test((), |mut app| async move {
let model = app.add_model(|ctx| {
let mut model = EditorModel::new("hello".into(), 0, None, ValidInputType::All, ctx);
model.set_interaction_state(InteractionState::Selectable);
model
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "hello".to_string());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(0)..ByteOffset::from(0)
);
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new()
.with_change_selections(|model, ctx| {
model.cursor_line_end(/* keep_selection */ false, ctx);
})
.with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("world", None, ctx),
),
)
});
// Only the selection state should have changed.
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "hello".to_string());
assert!(model.is_single_cursor_only(ctx));
let selection = model.first_selection(ctx);
assert_eq!(
model.selection_to_byte_offset(selection, ctx),
ByteOffset::from(5)..ByteOffset::from(5)
);
});
})
}
#[test]
fn test_ephemeral_edit() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(!model.is_ephemeral());
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
// Ephemeral edits should use the latest buffer as the "base text".
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::IsEphemeral,
|model, ctx| model.insert("world", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
});
model.update(&mut app, |model, ctx| model.undo(ctx));
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello"));
});
})
}
#[test]
fn test_materialize_ephemeral_edit() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(!model.is_ephemeral());
});
// Make an ephemeral edit.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::IsEphemeral,
|model, ctx| model.insert("hello world", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello world"));
});
// Materialize it.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new()
.with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("earth", None, ctx),
)
.with_change_selections(|model, ctx| {
model.select_word_left(ctx);
}),
)
});
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello earth"));
});
// Materialized ephemeral edits should be properly put on the undo stack.
model.update(&mut app, |model, ctx| model.undo(ctx));
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello world"));
});
model.update(&mut app, |model, ctx| model.undo(ctx));
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert!(model.buffer_text(ctx).is_empty());
});
})
}
#[test]
fn test_replace_ephemeral_edit_with_ephemeral() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(!model.is_ephemeral());
});
// Make an ephemeral edit.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::IsEphemeral,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello"));
});
// Make another ephemeral edit.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::IsEphemeral,
|model, ctx| model.insert("world", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
});
// Materialize the edits.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| model.insert("!", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("helloworld!"));
});
// There should only be two entries on the undo stack: the non-ephemeral edit and one edit for the ephemeral edits.
model.update(&mut app, |model, ctx| model.undo(ctx));
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("helloworld"));
});
model.update(&mut app, |model, ctx| model.undo(ctx));
model.read(&app, |model, ctx| {
assert!(!model.is_ephemeral());
assert!(model.buffer_text(ctx).is_empty());
});
})
}
#[test]
fn test_selection_change_doesnt_materialize_ephemeral_edit() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(!model.is_ephemeral());
});
// Make an ephemeral edit.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer_options(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
UpdateBufferOption::IsEphemeral,
|model, ctx| model.insert("hello", None, ctx),
),
)
});
model.read(&app, |model, ctx| {
assert!(model.is_ephemeral());
assert_eq!(model.buffer_text(ctx), String::from("hello"));
});
// Change selections.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_change_selections(|model, ctx| model.select_word_left(ctx)),
)
});
// The ephemeral buffer should still be active.
model.read(&app, |model, ctx| {
assert!(!model.selections(ctx).is_empty());
assert!(model.is_ephemeral());
});
})
}
// Regression test for CORE-1549.
#[test]
fn test_restoring_invalid_selections() {
App::test((), |mut app| async move {
let model =
app.add_model(|ctx| EditorModel::new("".into(), 0, None, ValidInputType::All, ctx));
model.read(&app, |model, ctx| {
assert!(model.buffer_text(ctx).is_empty());
assert!(!model.is_ephemeral());
});
// Restore from a snapshot where the selection range is invalid.
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| {
let snapshot = EditorSnapshot {
selections: vec1![CharOffset::from(5)..CharOffset::from(6)],
buffer_text_runs: vec![TextRun::new(
"foo".into(),
TextStyle::new(),
ByteOffset::from(0)..ByteOffset::from(3),
)],
};
model.restore_from_snapshot(snapshot, ctx);
},
),
)
});
// The selection range should fallback to (0, 0).
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "foo");
assert_eq!(
model.as_snapshot(ctx).selections,
vec1![CharOffset::from(0)..CharOffset::from(0)]
);
});
});
}
#[test]
fn test_undo_redo() {
App::test((), |mut app| async move {
let model = app
.add_model(|ctx| EditorModel::new("foo bar".into(), 0, None, ValidInputType::All, ctx));
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_change_selections(|model, ctx| {
model
.select_ranges_by_offset([CharOffset::from(2)..CharOffset::from(5)], ctx)
.unwrap();
}),
)
});
model.update(&mut app, |model, ctx| {
model.edit(
ctx,
Edits::new().with_update_buffer(
PlainTextEditorViewAction::ReplaceBuffer,
EditOrigin::UserInitiated,
|model, ctx| {
model.insert("z", None, ctx);
},
),
)
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "fozar");
assert_eq!(model.displayed_text(ctx), "fozar");
assert_eq!(
model.as_snapshot(ctx).selections,
vec1![CharOffset::from(3)..CharOffset::from(3)]
);
});
model.update(&mut app, |model, ctx| {
model.undo(ctx);
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "foo bar");
assert_eq!(model.displayed_text(ctx), "foo bar");
// TODO: we should consider making snapshot selections
// more intuitive rather than switching between start / end
// when snapshotting and restoring.
assert_eq!(
model.as_snapshot(ctx).selections,
vec1![CharOffset::from(5)..CharOffset::from(2)]
);
});
model.update(&mut app, |model, ctx| {
model.redo(ctx);
});
model.read(&app, |model, ctx| {
assert_eq!(model.buffer_text(ctx), "fozar");
assert_eq!(model.displayed_text(ctx), "fozar");
assert_eq!(
model.as_snapshot(ctx).selections,
vec1![CharOffset::from(3)..CharOffset::from(3)]
);
});
});
}
+621
View File
@@ -0,0 +1,621 @@
use std::{cmp::Ordering, mem, ops::Range};
use pathfinder_geometry::vector::Vector2F;
use serde::{Deserialize, Serialize};
use string_offset::{ByteOffset, CharOffset};
use vec1::Vec1;
use warpui::text::point::Point;
use warpui::AppContext;
use super::{
buffer::{Anchor, Buffer, LamportValue, ToBufferOffset, ToCharOffset, ToPoint},
display_map::{DisplayMap, ToDisplayPoint},
DisplayPoint, ReplicaId,
};
use crate::{
editor::{
soft_wrap::{ClampDirection, DisplayPointAndClampDirection},
CursorColors, RangeExt,
},
ui_components::avatar::Avatar,
};
/// This type encapsulates enough information about a selection to be able to
/// draw it. Compared to the `Selection` type, the points are converted based on
/// the `DisplayMap` to `DisplayPoint`s.
pub struct DrawableSelection {
pub range: Range<DisplayPoint>,
pub clamp_direction: ClampDirection,
pub replica_id: ReplicaId,
}
/// This type holds additional information about how to draw a local peer's
/// selections and cursors.
pub struct LocalDrawableSelectionData {
pub colors: CursorColors,
pub should_draw_cursors: bool,
}
/// This type holds additional information about how to draw a remote peer's
/// selections and cursors.
pub struct RemoteDrawableSelectionData {
pub colors: CursorColors,
pub should_draw_cursors: bool,
pub avatar: Avatar,
}
/// The minimal set of data to identify a selection
/// in the system.
///
/// For local selections, we need more information
/// so that we know how to extend them (see [`LocalSelection`]).
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Selection {
/// The start of the selection.
/// The `start` is always <= the `end`,
/// even if the selection is reversed.
pub start: Anchor,
pub end: Anchor,
/// Whether or not the selection is reversed.
/// If true, that means that the `start` is
/// where the "head" of the selection is (i.e.
/// where the cursor should be drawn, where the
/// selection should be extended from, etc.).
pub reversed: bool,
}
impl Selection {
pub fn single_cursor(cursor: Anchor) -> Selection {
Selection {
start: cursor.clone(),
end: cursor,
reversed: false,
}
}
pub fn head(&self) -> &Anchor {
if self.reversed {
&self.start
} else {
&self.end
}
}
pub fn tail(&self) -> &Anchor {
if self.reversed {
&self.end
} else {
&self.start
}
}
pub fn range(&self, buffer: &Buffer) -> Range<Point> {
let start = self.start.to_point(buffer).unwrap();
let end = self.end.to_point(buffer).unwrap();
if self.reversed {
end..start
} else {
start..end
}
}
pub fn display_range(&self, map: &DisplayMap, app: &AppContext) -> Range<DisplayPoint> {
let start = self.start.to_display_point(map, app).unwrap();
let end = self.end.to_display_point(map, app).unwrap();
if self.reversed {
end..start
} else {
start..end
}
}
pub fn is_cursor_only(&self, buffer: &Buffer) -> bool {
let start = self.start.to_point(buffer).unwrap();
let end = self.end.to_point(buffer).unwrap();
start == end
}
/// Whether the selection encompasses the entire buffer.
pub fn spans_entire_buffer(&self, buffer: &Buffer) -> bool {
self.range(buffer).sorted() == (Point::zero(), buffer.max_point())
}
pub fn to_offset(&self, buffer: &Buffer) -> Range<CharOffset> {
let start = self
.head()
.to_char_offset(buffer)
.expect("Should be able to convert selection head to offset.");
let end = self
.tail()
.to_char_offset(buffer)
.expect("Should be able to convert selection tail to offset.");
start..end
}
pub fn to_byte_offset(&self, buffer: &Buffer) -> Range<ByteOffset> {
let start = self
.head()
.to_byte_offset(buffer)
.expect("Should be able to convert selection head to offset.");
let end = self
.tail()
.to_byte_offset(buffer)
.expect("Should be able to convert selection tail to offset.");
start..end
}
pub fn start_to_point(&self, buffer: &Buffer) -> Point {
self.start
.to_point(buffer)
.expect("Selection start should be valid Point")
}
pub fn end_to_point(&self, buffer: &Buffer) -> Point {
self.end
.to_point(buffer)
.expect("Selection end should be valid Point")
}
/// Returns the selections display range iff it intersects the provided `range`.
fn intersects_display_range(
&self,
range: &Range<DisplayPoint>,
map: &DisplayMap,
app: &AppContext,
) -> Option<Range<DisplayPoint>> {
let display_range = self.display_range(map, app);
// TODO (suraj): this check is confusing because [`Selection::display_range`]
// possibly returns a reversed range.
let intersects = display_range.start <= range.end || display_range.end <= range.end;
intersects.then_some(display_range)
}
}
/// A selection made by the client itself.
/// Since the editor is CRDT-compliant, we distinguish
/// between local and remote (peer) selections.
///
/// A local selection contains enough information
/// to identify the selection as well as _extend_ it.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LocalSelection {
pub selection: Selection,
pub clamp_direction: ClampDirection,
// goal_*_column's represent the columns this selection really should take.
// Sometimes, `start`/`end` might be less than their goal_*_column counterparts
// so we store these to use them once enough characters are present. For empty
// selections, goal_start_column == goal_end_column, similar to start == end.
//
// As a concrete example, suppose we had the following buffer:
// "words
// wo
// words2"
// If the existing selection is ds on the first line, then if we add a cursor below,
// it becomes an empty selection at the end of the line. Specifically, the buffer now looks like:
// "wor<ds>|
// wo|
// words2"
// where <x> denotes a selection of string x. Now, if we add one more cursor below,
// we want the buffer to look like the following
// "wor<ds>|
// wo|
// wor<ds>|2"
// That is, if we didn't keep track of the goal start _and_ end columns,
// we would not be able to correctly make the selection on the last row.
// Specifically, the empty selection on the middle row had the following information stored:
// {goal_start_column: 3, goal_end_column: 5}, which is how we were able to make the selection
// correctly on the last row. If we only kept a single goal_column, we would either lose
// information about the start of the selection or the end of the selection.
pub goal_start_column: Option<u32>,
pub goal_end_column: Option<u32>,
}
impl LocalSelection {
pub fn start(&self) -> &Anchor {
&self.selection.start
}
pub fn end(&self) -> &Anchor {
&self.selection.end
}
pub fn head(&self) -> &Anchor {
self.selection.head()
}
pub fn tail(&self) -> &Anchor {
self.selection.tail()
}
pub fn reversed(&self) -> bool {
self.selection.reversed
}
pub fn range(&self, buffer: &Buffer) -> Range<Point> {
self.selection.range(buffer)
}
pub fn display_range(&self, map: &DisplayMap, app: &AppContext) -> Range<DisplayPoint> {
self.selection.display_range(map, app)
}
pub fn is_cursor_only(&self, buffer: &Buffer) -> bool {
self.selection.is_cursor_only(buffer)
}
/// Whether the selection encompasses the entire buffer.
pub fn spans_entire_buffer(&self, buffer: &Buffer) -> bool {
self.selection.spans_entire_buffer(buffer)
}
pub fn to_offset(&self, buffer: &Buffer) -> Range<CharOffset> {
self.selection.to_offset(buffer)
}
pub fn to_byte_offset(&self, buffer: &Buffer) -> Range<ByteOffset> {
self.selection.to_byte_offset(buffer)
}
pub fn start_to_point(&self, buffer: &Buffer) -> Point {
self.selection.start_to_point(buffer)
}
pub fn end_to_point(&self, buffer: &Buffer) -> Point {
self.selection.end_to_point(buffer)
}
pub fn set_start(&mut self, new_start: Anchor) {
self.selection.start = new_start;
}
pub fn set_end(&mut self, new_end: Anchor) {
self.selection.end = new_end;
}
pub fn set_reversed(&mut self, reversed: bool) {
self.selection.reversed = reversed;
}
pub fn set_selection(&mut self, selection: Selection) {
self.selection = selection;
}
pub fn set_head(&mut self, buffer: &Buffer, cursor: Anchor) {
if cursor
.cmp(self.tail(), buffer)
.expect("Anchors should be comparable")
< Ordering::Equal
{
if !self.selection.reversed {
mem::swap(&mut self.selection.start, &mut self.selection.end);
self.selection.reversed = true;
}
self.selection.start = cursor;
} else {
if self.selection.reversed {
mem::swap(&mut self.selection.start, &mut self.selection.end);
self.selection.reversed = false;
}
self.selection.end = cursor;
}
}
pub fn set_tail(&mut self, buffer: &Buffer, cursor: Anchor) {
if cursor
.cmp(self.head(), buffer)
.expect("Anchors should be comparable")
> Ordering::Equal
{
if !self.selection.reversed {
mem::swap(&mut self.selection.start, &mut self.selection.end);
self.selection.reversed = true;
}
self.selection.end = cursor;
} else {
if self.selection.reversed {
mem::swap(&mut self.selection.start, &mut self.selection.end);
self.selection.reversed = false;
}
self.selection.start = cursor;
}
}
#[cfg(test)]
pub fn new_for_test(start: Anchor, end: Anchor) -> Self {
Self {
selection: Selection {
start,
end,
reversed: false,
},
clamp_direction: ClampDirection::Down,
goal_end_column: None,
goal_start_column: None,
}
}
}
/// A ongoing selection made by the client itself.
/// There is no concept of a pending remote selection.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LocalPendingSelection {
pub selection: LocalSelection,
/// Determines how to extend selection based on starting selection.
pub selection_mode: SelectionMode,
/// Saved selection to refer back to when updating new selection.
/// Used when extending selection from an already selected word.
pub starting_selection: LocalSelection,
pub is_single_selection: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum MarkedTextState {
/// There is currently marked text in the editor.
Active {
/// The current selection given by the IME within the marked text.
selected_range: Range<usize>,
},
/// There is no marked text in the editor.
#[default]
Inactive,
}
/// The set of active and pending selections for this client.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LocalSelections {
/// A pending selection state, if any. This is used
/// when a selection is actively being changed (e.g. mouse down).
pub pending: Option<LocalPendingSelection>,
/// The set of established selections.
/// There is always at least one selection.
pub selections: Vec1<LocalSelection>,
/// Indicates whether or not the selections are marked text.
pub marked_text_state: MarkedTextState,
}
impl LocalSelections {
pub fn new(selections: Vec1<LocalSelection>, marked_text_state: MarkedTextState) -> Self {
Self {
selections,
pending: None,
marked_text_state,
}
}
pub fn first(&self) -> &LocalSelection {
self.selections.first()
}
pub fn last(&self) -> &LocalSelection {
self.selections.last()
}
pub fn selection_insertion_index(&self, start: &Anchor, buffer: &Buffer) -> usize {
selection_insertion_index(&self.selections, start, buffer)
}
pub fn selections_intersecting_range<'a>(
&'a self,
range: Range<DisplayPoint>,
map: &'a DisplayMap,
app: &'a AppContext,
) -> impl Iterator<Item = (&'a LocalSelection, Range<DisplayPoint>)> + 'a {
let pending_selection = self.pending.as_ref().and_then(|s| {
// If there's a single pending selection, it's already in the selections vector.
if s.is_single_selection {
return None;
}
let selection = &s.selection;
s.selection
.selection
.intersects_display_range(&range, map, app)
.map(|r| (selection, r))
});
selections_intersecting_range(&self.selections, range, map, app).chain(pending_selection)
}
pub fn drawable_selections_intersecting_range<'a>(
&'a self,
range: Range<DisplayPoint>,
replica_id: ReplicaId,
map: &'a DisplayMap,
app: &'a AppContext,
) -> impl Iterator<Item = DrawableSelection> + 'a {
self.selections_intersecting_range(range, map, app).map(
move |(selection, selection_range)| DrawableSelection {
range: selection_range,
clamp_direction: selection.clamp_direction,
replica_id: replica_id.clone(),
},
)
}
pub fn marked_text_state(&self) -> MarkedTextState {
self.marked_text_state.clone()
}
pub fn set_marked_text_state(&mut self, marked_text_state: MarkedTextState) {
self.marked_text_state = marked_text_state;
}
}
impl From<Vec1<LocalSelection>> for LocalSelections {
fn from(selections: Vec1<LocalSelection>) -> Self {
Self {
selections,
pending: None,
marked_text_state: Default::default(),
}
}
}
/// Determines how to extend selection for text.
/// Either extending by characters, lines or words at a time.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SelectionMode {
Chars,
Lines,
Words,
}
/// A selection range for a remote peer.
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct RemoteSelection {
pub selection: Selection,
}
impl RemoteSelection {
pub fn observed(&self, buffer: &Buffer) -> bool {
self.selection.start.observed(buffer) && self.selection.end.observed(buffer)
}
}
impl From<LocalSelection> for RemoteSelection {
fn from(local: LocalSelection) -> Self {
Self {
selection: local.selection,
}
}
}
/// The set of selections for a remote client.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteSelections {
/// The selections themselves.
pub selections: Vec1<RemoteSelection>,
/// The lamport timestamp of the update
/// associated to this latest selection set.
pub lamport: LamportValue,
}
impl RemoteSelections {
pub fn drawable_selections_intersecting_range<'a>(
&'a self,
range: Range<DisplayPoint>,
replica_id: ReplicaId,
map: &'a DisplayMap,
app: &'a AppContext,
) -> impl Iterator<Item = DrawableSelection> + 'a {
selections_intersecting_range(&self.selections, range, map, app).map(
move |(_, selection_range)| DrawableSelection {
range: selection_range,
clamp_direction: ClampDirection::default(),
replica_id: replica_id.clone(),
},
)
}
}
#[derive(Debug)]
pub enum SelectAction {
Begin {
position: DisplayPointAndClampDirection,
add: bool,
},
Update {
position: DisplayPoint,
scroll_position: Vector2F,
},
Extend {
position: DisplayPoint,
scroll_position: Vector2F,
},
End,
}
impl SelectAction {
/// Create an action for beginning a selection - use this in tests that need to simulate
/// clicking on an editor.
#[cfg(test)]
pub fn begin(point: DisplayPoint) -> Self {
Self::Begin {
position: DisplayPointAndClampDirection {
point,
clamp_direction: Default::default(),
},
add: false,
}
}
}
pub trait AsSelection {
fn as_selection(&self) -> &Selection;
fn as_mut_selection(&mut self) -> &mut Selection;
}
impl AsSelection for LocalSelection {
fn as_selection(&self) -> &Selection {
&self.selection
}
fn as_mut_selection(&mut self) -> &mut Selection {
&mut self.selection
}
}
impl AsSelection for RemoteSelection {
fn as_selection(&self) -> &Selection {
&self.selection
}
fn as_mut_selection(&mut self) -> &mut Selection {
&mut self.selection
}
}
fn selection_insertion_index<S: AsSelection>(
selections: &Vec1<S>,
start: &Anchor,
buffer: &Buffer,
) -> usize {
match selections
.binary_search_by(|probe| probe.as_selection().start.cmp(start, buffer).unwrap())
{
Ok(index) => index,
Err(index) => {
if index > 0
&& selections[index - 1]
.as_selection()
.end
.cmp(start, buffer)
.unwrap()
== Ordering::Greater
{
index - 1
} else {
index
}
}
}
}
/// Returns an iterator over the selections
/// that intersect the given `range`.
///
/// Assumes that `selections` are sorted by their start point.
fn selections_intersecting_range<'a, S: AsSelection>(
selections: &'a Vec1<S>,
range: Range<DisplayPoint>,
map: &'a DisplayMap,
app: &'a AppContext,
) -> impl Iterator<Item = (&'a S, Range<DisplayPoint>)> + 'a {
let start = map
.anchor_before(range.start, super::Bias::Left, app)
.unwrap();
let start_index = selection_insertion_index(selections, &start, map.buffer(app));
selections[start_index..]
.iter()
.map_while(move |selection| {
selection
.as_selection()
.intersects_display_range(&range, map, app)
.map(|r| (selection, r))
})
}
+34
View File
@@ -0,0 +1,34 @@
use super::{DisplayMap, DisplayPoint};
use anyhow::Result;
use warpui::AppContext;
pub fn left(
map: &DisplayMap,
mut point: DisplayPoint,
app: &AppContext,
stop_at_line_start: bool,
) -> Result<DisplayPoint> {
if point.column() > 0 {
*point.column_mut() -= 1;
} else if !stop_at_line_start && point.row() > 0 {
*point.row_mut() -= 1;
*point.column_mut() = map.line_len(point.row(), app)?;
}
Ok(point)
}
pub fn right(
map: &DisplayMap,
mut point: DisplayPoint,
app: &AppContext,
stop_at_line_end: bool,
) -> Result<DisplayPoint> {
let max_column = map.line_len(point.row(), app).unwrap();
if point.column() < max_column {
*point.column_mut() += 1;
} else if !stop_at_line_end && point.row() < map.max_point(app).row() {
*point.row_mut() += 1;
*point.column_mut() = 0;
}
Ok(point)
}
+666
View File
@@ -0,0 +1,666 @@
use super::model::EditorModel;
use super::{
AutosuggestionLocation, AutosuggestionState, AutosuggestionType,
BaselinePositionComputationMethod, Bias, DisplayPoint, DrawableSelection, ScrollState,
ToBufferOffset, ToDisplayPoint,
};
use super::{ToCharOffset, ToPoint};
#[cfg(feature = "voice_input")]
use crate::editor::view::voice::VoiceInputState;
use crate::editor::soft_wrap::FrameLayouts;
use crate::terminal::grid_size_util::grid_compute_baseline_position_fn;
use parking_lot::Mutex;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use anyhow::Result;
use core::f32;
use instant::Instant;
use rayon::prelude::*;
use std::borrow::Cow;
use std::collections::HashMap;
use std::time::Duration;
use std::{
cmp::{self},
ops::Range,
sync::Arc,
};
use warp_completer::completer::Description;
use warpui::text::point::Point;
use string_offset::ByteOffset;
use warpui::fonts::{FamilyId, Properties};
use warpui::platform::LineStyle;
use warpui::text_layout::{
default_compute_baseline_position_fn, ClipConfig, ComputeBaselinePositionFn, StyleAndFont,
TextAlignment, TextStyle, DEFAULT_TOP_BOTTOM_RATIO,
};
use warpui::EntityId;
use warpui::{
fonts::Cache as FontCache,
text_layout::{self, LayoutCache},
AppContext, ModelHandle,
};
/// Ratio to calculate font size of cursor avatar.
/// Found experimentally to scale the best proportionally with
/// current font size and the avatar's size.
pub const CURSOR_AVATAR_FONT_RATIO: f32 = 0.8;
/// Offset to calculate size of cursor avatar.
/// Found experimentally to look the best with current font size.
pub const CURSOR_AVATAR_IMAGE_OFFSET: f32 = 4.;
/// Fudge factor to make the voice input icon slightly wider than it is tall.
const VOICE_INPUT_ICON_IMAGE_OFFSET_X: f32 = 5.;
/// Minimum size of voice input icon.
const MIN_VOICE_INPUT_ICON_SIZE: f32 = 16.;
/// Gap between voice input icon's botton and cursor's top.
pub const VOICE_INPUT_ICON_CURSOR_GAP: f32 = 2.;
/// The amount of time the editor height must have remained shrunken
/// before we actually shrink the height. This is to prevent jittering
/// before an autosuggestion is computed on keypress, if the autosuggestion would wrap
/// and cause the editor height to grow.
const EDITOR_HEIGHT_SHRINK_DELAY: Duration = Duration::from_millis(25);
/// A read-only snapshot of the [`EditorView`] that is needed
/// to render the [`EditorElement`].
pub struct ViewSnapshot {
pub view_id: EntityId,
pub is_focused: bool,
pub editor_model: ModelHandle<EditorModel>,
pub can_select: bool,
pub font_size: f32,
pub font_family: FamilyId,
pub placeholder_font_family: FamilyId,
pub font_properties: Properties,
pub line_height: f32,
pub line_height_ratio: f32,
pub em_width: f32,
pub autogrow: bool,
pub is_empty: bool,
/// Map from prefix to placeholder text. The view can have multiple active prefixes that when
/// exactly matched in the buffer cause additional placeholder ghost text to be displayed. The
/// empty string prefix "" is the default placeholder (shown when buffer is empty).
pub placeholder_texts: Arc<HashMap<String, String>>,
pub autosuggestion_state: Option<Arc<AutosuggestionState>>,
pub command_xray: Option<Arc<Description>>,
pub cached_buffer_points: HashMap<Cow<'static, str>, Point>,
pub baseline_position_computation_method: BaselinePositionComputationMethod,
#[cfg(feature = "voice_input")]
pub voice_input_state: VoiceInputState,
pub editor_height_shrink_delay: Arc<Mutex<EditorHeightShrinkDelay>>,
}
/// A struct to hold the editor height before it was shrunk and the time it was first shrunk.
/// This is used to delay shrinking the editor height until after it's remained shrunken for EDITOR_HEIGHT_SHRINK_DELAY.
pub struct EditorHeightShrinkDelay {
pub editor_height_before_shrink: f32,
pub editor_height_shrink_start: Option<Instant>,
}
impl ViewSnapshot {
/// Returns the editor height with the EDITOR_HEIGHT_SHRINK_DELAY applied, and updates internal state.
pub fn get_editor_height_with_shrink_delay(&self, editor_height: f32) -> f32 {
let mut editor_height_shrink_delay = self.editor_height_shrink_delay.lock();
// If the editor_height stayed the same or grew, use editor_height and reset the shrink time.
if editor_height >= editor_height_shrink_delay.editor_height_before_shrink {
editor_height_shrink_delay.editor_height_before_shrink = editor_height;
editor_height_shrink_delay.editor_height_shrink_start = None;
return editor_height;
}
// From here we know the editor height shrank.
// If there's no start time recorded, this is the first render where the editor height shrunk.
// Record the current time but use the height before shrinking.
let Some(editor_height_shrink_start) =
editor_height_shrink_delay.editor_height_shrink_start
else {
editor_height_shrink_delay.editor_height_shrink_start = Some(Instant::now());
return editor_height_shrink_delay.editor_height_before_shrink;
};
// If the height has been shrunken for a while, use the latest shrunken height.
// It is our new baseline.
if editor_height_shrink_start.elapsed() > EDITOR_HEIGHT_SHRINK_DELAY {
editor_height_shrink_delay.editor_height_before_shrink = editor_height;
editor_height_shrink_delay.editor_height_shrink_start = None;
editor_height
} else {
editor_height_shrink_delay.editor_height_before_shrink
}
}
/// Returns the time at which the editor height should be repainted with the shrunken editor height.
pub fn get_editor_repaint_at(&self) -> Option<Instant> {
self.editor_height_shrink_delay
.lock()
.editor_height_shrink_start
.map(|start| start + EDITOR_HEIGHT_SHRINK_DELAY)
}
pub fn baseline_position_fn(&self) -> ComputeBaselinePositionFn {
// Copy the font family ID so that it can be moved into the closure.
let font_family = self.font_family;
match self.baseline_position_computation_method {
BaselinePositionComputationMethod::Grid => {
grid_compute_baseline_position_fn(font_family)
}
BaselinePositionComputationMethod::Default => default_compute_baseline_position_fn(),
}
}
/// Returns the placeholder text for the prefix that matches the current buffer content.
pub fn matching_placeholder_text(&self, buffer_text: &str) -> Option<String> {
// Find exact match - buffer content must equal the prefix exactly
self.placeholder_texts
.iter()
.find(|(prefix, _)| buffer_text == prefix.as_str())
.map(|(_, text)| text.clone())
}
pub fn placeholder_text_exists(&self) -> bool {
// Only consider the default placeholder (empty prefix) for "exists" checks.
// This is used when laying out the input and determining input height.
self.placeholder_texts.contains_key("")
}
pub fn is_selecting(&self, app: &AppContext) -> bool {
self.editor_model.as_ref(app).is_selecting(app)
}
pub fn rightmost_point(&self, app: &AppContext) -> DisplayPoint {
self.editor_model
.as_ref(app)
.display_map(app)
.rightmost_point()
}
pub fn max_point(&self, app: &AppContext) -> DisplayPoint {
self.editor_model.as_ref(app).max_point(app)
}
pub fn autosuggestion_location(&self) -> Option<AutosuggestionLocation> {
self.autosuggestion_state
.as_ref()
.map(|state| state.location)
}
pub fn active_autosuggestion(&self) -> bool {
self.autosuggestion_state
.as_ref()
.is_some_and(|s| s.is_active())
}
pub fn active_next_command_suggestion(&self) -> bool {
self.autosuggestion_state.as_ref().is_some_and(|s| {
s.is_active()
&& matches!(
s.autosuggestion_type,
AutosuggestionType::Command {
was_intelligent_autosuggestion: true
}
)
})
}
/// Lays out the given ghosted text, which can be a placeholder or autosuggestion.
fn layout_ghosted_text(
&self,
text: &str,
size: &Vector2F,
soft_wrap: bool,
first_line_head_indent: Option<f32>,
font_cache: &FontCache,
layout_cache: &LayoutCache,
) -> Vec<Arc<text_layout::Line>> {
let font_size = self.font_size;
if soft_wrap {
let text_frame = layout_cache.layout_text(
text,
LineStyle {
font_size: self.font_size,
line_height_ratio: self.line_height_ratio,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(
0..text.chars().count(),
StyleAndFont::new(
self.placeholder_font_family,
self.font_properties,
TextStyle::new(),
),
)],
size.x(),
f32::MAX,
Default::default(),
first_line_head_indent,
&font_cache.text_layout_system(),
);
// Return a vec of lines to be backward compatible with laying out placeholder
// without soft_wrapping and autosuggestion. In the future we may want to
// return a TextFrame here so we don't have to clone the text_frame lines
text_frame
.lines()
.iter()
.map(|l| Arc::new(l.clone()))
.collect()
} else {
text.lines()
.map(|line| {
layout_cache.layout_line(
line,
LineStyle {
font_size,
line_height_ratio: self.line_height_ratio,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(
0..line.chars().count(),
StyleAndFont::new(
self.placeholder_font_family,
self.font_properties,
TextStyle::new(),
),
)],
f32::MAX,
ClipConfig::default(),
&font_cache.text_layout_system(),
)
})
.collect()
}
}
pub fn layout_autosuggestion(
&self,
preceding_text_width: f32,
font_cache: &FontCache,
layout_cache: &LayoutCache,
size: &Vector2F,
soft_wrap: bool,
) -> Vec<Arc<text_layout::Line>> {
// The autosuggestion is laid out on the same line as editor text and then soft wraps.
// This means the first line of the autosuggestion has preceding_text_width less width to use before it needs to wrap.
self.autosuggestion_state
.as_ref()
.and_then(|state| state.current_autosuggestion_text.as_ref())
.map(|current_autosuggestion_text| {
self.layout_ghosted_text(
current_autosuggestion_text,
size,
soft_wrap,
Some(preceding_text_width),
font_cache,
layout_cache,
)
})
.unwrap_or_default()
}
/// Layout placeholder text with an optional indent for the first line.
pub fn layout_placeholder_text(
&self,
placeholder_text: &str,
first_line_indent: f32,
font_cache: &FontCache,
layout_cache: &LayoutCache,
size: &Vector2F,
soft_wrap: bool,
) -> Vec<Arc<text_layout::Line>> {
self.layout_ghosted_text(
placeholder_text,
size,
soft_wrap,
if first_line_indent > 0. {
Some(first_line_indent)
} else {
None
},
font_cache,
layout_cache,
)
}
pub(super) fn clamp_scroll_left(&self, scroll_state: &ScrollState, max: f32) {
let mut scroll_position = scroll_state.scroll_position.lock();
let scroll_left = scroll_position.x();
scroll_position.set_x(scroll_left.min(max));
}
pub fn max_scroll_top(total_lines: f32, visible_lines: f32) -> f32 {
(total_lines - visible_lines).max(0.)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn autoscroll_horizontally(
&self,
scroll_state: &ScrollState,
start_row: u32,
viewport_width: f32,
scroll_width: f32,
max_glyph_width: f32,
layouts: Vec<&text_layout::Line>,
app: &AppContext,
) {
let map = self.editor_model.as_ref(app).display_map(app);
let mut target_left = f32::INFINITY;
let mut target_right = 0.0_f32;
for selection in self.editor_model.as_ref(app).selections(app) {
let head = selection.head().to_display_point(map, app).unwrap();
let start_column = head.column().saturating_sub(3);
let end_column = cmp::min(map.line_len(head.row(), app).unwrap(), head.column() + 3);
if let Some(line) = layouts.get((head.row() - start_row) as usize) {
target_left = target_left.min(line.x_for_index(start_column as usize));
}
if let Some(line) = layouts.get((head.row() - start_row) as usize) {
target_right =
target_right.max(line.x_for_index(end_column as usize) + max_glyph_width);
}
}
target_right = target_right.min(scroll_width);
if target_right - target_left > viewport_width {
return;
}
let mut scroll_position = scroll_state.scroll_position.lock();
let scroll_left = scroll_position.x() * max_glyph_width;
let scroll_right = scroll_left + viewport_width;
if target_left < scroll_left {
scroll_position.set_x(target_left / max_glyph_width);
} else if target_right > scroll_right {
scroll_position.set_x((target_right - viewport_width) / max_glyph_width);
}
}
pub(super) fn autoscroll_vertically(
&self,
scroll_state: &ScrollState,
total_lines: f32,
visible_lines: f32,
top_section_height_lines: f32,
frame_layouts: &FrameLayouts,
app: &AppContext,
) -> bool {
let mut scroll_position = scroll_state.scroll_position.lock();
let scroll_top = scroll_position.y();
scroll_position.set_y(scroll_top.min(total_lines - visible_lines).max(0.));
let mut autoscroll_requested = scroll_state.autoscroll_requested.lock();
if *autoscroll_requested {
*autoscroll_requested = false;
} else {
return false;
}
let map = self.editor_model.as_ref(app).display_map(app);
let first_selection = self.editor_model.as_ref(app).first_selection(app);
let first_selection_clamp_direction = first_selection.clamp_direction;
let first_cursor = first_selection.head().to_display_point(map, app);
let first_cursor_top = match first_cursor {
Ok(first_cursor) => {
match frame_layouts
.to_soft_wrap_point(first_cursor, first_selection_clamp_direction)
{
Some(point) => point.row() as f32 + top_section_height_lines,
None => {
log::error!("Failed to get softwrapped point from display point");
return false;
}
}
}
Err(err) => {
log::error!("Error trying to turn selection into display point {err:?}");
return false;
}
};
let last_selection = self.editor_model.as_ref(app).last_selection(app);
let last_selection_clamp_direction = last_selection.clamp_direction;
let last_cursor = last_selection.head().to_display_point(map, app);
let last_cursor_bottom = match last_cursor {
Ok(last_cursor) => {
match frame_layouts.to_soft_wrap_point(last_cursor, last_selection_clamp_direction)
{
Some(point) => point.row() as f32 + 1.0 + top_section_height_lines,
None => {
log::error!("Failed to get softwrapped point from display point");
return false;
}
}
}
Err(err) => {
log::error!("Error trying to turn selection into display point {err:?}");
return false;
}
};
let margin = ((visible_lines - (last_cursor_bottom - first_cursor_top)) / 2.0)
.floor()
.min(3.0);
if margin < 0.0 {
return false;
}
let target_top = (first_cursor_top - margin).max(0.0);
let target_bottom = last_cursor_bottom + margin;
let start_row = scroll_position.y();
let end_row = start_row + visible_lines;
if target_top < start_row {
scroll_position.set_y(target_top.min(Self::max_scroll_top(total_lines, visible_lines)));
} else if target_bottom >= end_row {
scroll_position.set_y(
(target_bottom - visible_lines)
.min(Self::max_scroll_top(total_lines, visible_lines)),
);
}
true
}
/// If the range of rows passed does not exist, we just layout the rows that do exist
/// within the provided range.
pub fn layout_text_frames(
&self,
mut rows: Range<u32>,
layout_cache: &LayoutCache,
max_width: f32,
left_notch_width_px: f32,
app: &AppContext,
) -> Result<Vec<Arc<text_layout::TextFrame>>> {
let display_map = self.editor_model.as_ref(app).display_map(app);
rows.end = cmp::min(rows.end, display_map.max_point(app).row() + 1);
if rows.start >= rows.end {
return Ok(Vec::new());
}
let family_id = self.font_family;
let properties = self.font_properties;
let font_size = self.font_size;
// Collect style information for lines
let stylized_lines = rows
.map(|row| {
display_map
.chars_with_styles_at(DisplayPoint::new(row, 0), app)
.unwrap()
// Stop when we hit the end of the row.
.take_while(|c| c.char() != '\n')
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let font_cache = app.font_cache();
let line_height_ratio = self.line_height_ratio;
// Use rayon parallel iterators to efficiently lay out all of the lines.
let text_layout_system = font_cache.text_layout_system();
let layouts = stylized_lines
.into_par_iter()
.enumerate()
.map(|(cur_line_idx, stylized_chars)| {
let mut line = String::with_capacity(stylized_chars.len());
let mut style_runs = Vec::with_capacity(stylized_chars.len());
for (idx, stylized_char) in stylized_chars.into_iter().enumerate() {
// Accumulate both style information and the characters to lay out.
style_runs.push((
idx..idx + 1,
StyleAndFont::new(family_id, properties, stylized_char.style()),
));
line.push(stylized_char.char());
}
layout_cache.layout_text(
&line,
LineStyle {
font_size,
line_height_ratio,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&style_runs,
max_width,
f32::MAX,
TextAlignment::default(),
// Push over text by left notch width, if we're on the first line. Note notch width will be 0 if same line prompt is disabled.
(cur_line_idx == 0).then_some(left_notch_width_px),
&text_layout_system,
)
})
.collect();
Ok(layouts)
}
pub fn line(&self, display_row: u32, app: &AppContext) -> Result<String> {
self.editor_model
.as_ref(app)
.display_map(app)
.line(display_row, app)
}
pub fn all_drawable_selections_intersecting_range<'a>(
&'a self,
range: Range<DisplayPoint>,
app: &'a AppContext,
) -> impl 'a + Iterator<Item = DrawableSelection> {
self.editor_model
.as_ref(app)
.all_drawable_selections_intersecting_range(range, app)
}
// todo: dedup this with EditorView. it's really: given an editor model + row, get the line len
pub fn line_len(&self, display_row: u32, app: &AppContext) -> Result<u32> {
self.editor_model.as_ref(app).line_len(display_row, app)
}
pub fn layout_line(
&self,
row: u32,
text_layout_cache: &LayoutCache,
app: &AppContext,
) -> Result<Arc<text_layout::Line>> {
let font_cache = app.font_cache();
let line = self.line(row, app)?;
Ok(text_layout_cache.layout_line(
&line,
LineStyle {
font_size: self.font_size,
line_height_ratio: self.line_height_ratio,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
},
&[(
0..self.line_len(row, app)? as usize,
StyleAndFont::new(self.font_family, Default::default(), TextStyle::new()),
)],
f32::MAX,
ClipConfig::default(),
&font_cache.text_layout_system(),
))
}
/// Finds the byte offset directly under the given point
pub fn byte_offset_at_point(
&self,
point: &DisplayPoint,
app: &AppContext,
) -> Option<ByteOffset> {
let model = self.editor_model.as_ref(app);
let buffer = model.buffer(app);
let map = model.display_map(app);
point
.to_buffer_point(map, Bias::Left, app)
.and_then(|point| point.to_byte_offset(buffer))
.ok()
}
pub fn display_point_at_byte_offset(
&self,
byte_offset: &ByteOffset,
app: &AppContext,
) -> Option<DisplayPoint> {
let model = self.editor_model.as_ref(app);
let buffer = model.buffer(app);
let map = model.display_map(app);
let char_offset = byte_offset.to_char_offset(buffer).ok()?;
let point = char_offset.to_point(buffer).ok()?;
point.to_display_point(map, app).ok()
}
pub fn vim_visual_tails<'a>(
&self,
app: &'a AppContext,
) -> impl Iterator<Item = DisplayPoint> + 'a {
let editor_model = self.editor_model.as_ref(app);
let map = editor_model.display_map(app);
editor_model
.vim_visual_tails()
.iter()
.filter_map(|anchor| anchor.to_display_point(map, app).ok())
}
/// Returns the font size for a cursor avatar. Value is based on the snapshot's
/// font size and an avatar-specific ratio.
pub fn cursor_avatar_font_size(&self) -> f32 {
self.font_size * CURSOR_AVATAR_FONT_RATIO
}
/// Returns the size (diameter) for a cursor avatar. Value is based on the snapshot's
/// font size and an avatar-specific offset.
pub fn cursor_avatar_size(&self) -> f32 {
self.font_size + CURSOR_AVATAR_IMAGE_OFFSET
}
/// Returns the size to render the voice input icon at, scaled by the font size.
pub fn voice_input_icon_size(&self) -> Vector2F {
let scaled_size = (self.font_size + 1.).max(MIN_VOICE_INPUT_ICON_SIZE);
vec2f(scaled_size + VOICE_INPUT_ICON_IMAGE_OFFSET_X, scaled_size)
}
}
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
use super::{EditorAction, EditorView, VoiceTranscriptionOptions};
use crate::ai::blocklist::InputType;
use crate::appearance::Appearance;
use crate::editor::EditorElement;
use crate::server::server_api::TranscribeError;
use crate::server::telemetry::TelemetryEvent;
use crate::settings::{AISettings, VoiceInputToggleKey};
use crate::themes::theme::Fill;
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
use crate::ui_components::icons;
use crate::view_components::{FeaturePopup, NewFeaturePopupLabel};
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use settings::Setting as _;
use voice_input::{StartListeningError, VoiceInput, VoiceSessionResult};
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::theme::color::internal_colors;
use warp_core::ui::theme::AnsiColorIdentifier;
use warpui::elements;
use warpui::elements::{Container, CornerRadius, Icon, Radius};
use warpui::platform::Cursor;
use warpui::r#async::SpawnedFutureHandle;
use warpui::ui_components::button::ButtonTooltipPosition;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ViewHandle;
use warpui::{AppContext, Element, SingletonEntity, ViewContext};
use super::VoiceTranscriber;
const MICROPHONE_ACCESS_ERROR_ID: &str = "MICROPHONE_ACCESS_ERROR";
const NUM_TIMES_TO_SHOW_VOICE_NEW_FEATURE_POPUP: usize = 4;
#[derive(Debug, Default, Clone)]
pub(super) enum VoiceInputState {
#[default]
Stopped,
/// We are listening for voice input. This is happening in the singleton voice transcriber.
Listening,
/// We are done listening and are transcribing voice input.
Transcribing {
/// The handle to the future that is spawned for voice input while transcribing is taking place.
handle: SpawnedFutureHandle,
},
}
impl VoiceInputState {
pub(super) fn is_active(&self) -> bool {
matches!(
self,
VoiceInputState::Listening | VoiceInputState::Transcribing { .. }
)
}
pub(super) fn icon(&self) -> Option<icons::Icon> {
match self {
VoiceInputState::Listening => Some(icons::Icon::Microphone),
VoiceInputState::Transcribing { .. } => Some(icons::Icon::DotsHorizontal),
VoiceInputState::Stopped => None,
}
}
}
impl EditorView {
pub(super) fn is_voice_input_active(&self) -> bool {
self.voice_input_state.is_active()
}
pub(super) fn create_voice_new_feature_popup(
ctx: &mut ViewContext<EditorView>,
) -> ViewHandle<FeaturePopup> {
let voice_new_feature_popup = ctx.add_typed_action_view(|_| {
FeaturePopup::new_feature(NewFeaturePopupLabel::FromString(
"Try Voice Input".to_string(),
))
});
ctx.subscribe_to_view(&voice_new_feature_popup, |_me, _, event, ctx| {
if matches!(
event,
crate::view_components::NewFeaturePopupEvent::Dismissed
) {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
warp_core::report_if_error!(settings
.dismissed_voice_input_new_feature_popup
.set_value(true, ctx));
});
ctx.notify();
}
});
voice_new_feature_popup
}
pub(super) fn should_show_voice_new_feature_popup(&self, app: &AppContext) -> bool {
let ai_settings = AISettings::handle(app).as_ref(app);
let voice_input = voice_input::VoiceInput::handle(app).as_ref(app);
let num_times_entered_agent_mode = *ai_settings.entered_agent_mode_num_times;
let manually_dismissed_voice_input_new_feature_popup =
*ai_settings.dismissed_voice_input_new_feature_popup;
let explicitly_interacted_with_voice = *ai_settings.explicitly_interacted_with_voice;
num_times_entered_agent_mode <= NUM_TIMES_TO_SHOW_VOICE_NEW_FEATURE_POPUP
&& !manually_dismissed_voice_input_new_feature_popup
&& !explicitly_interacted_with_voice
&& !voice_input.should_suppress_new_feature_popup
}
/// Configures an [`EditorElement`] for the current voice input state.
pub(super) fn configure_editor_element_voice(
&self,
editor_element: EditorElement,
appearance: &Appearance,
) -> EditorElement {
if let Some(icon) = self.voice_input_state.icon() {
editor_element.with_voice_input_cursor_icon(
Container::new(
Icon::new(icon.into(), internal_colors::neutral_1(appearance.theme())).finish(),
)
.with_background(Fill::Solid(appearance.theme().accent().into()))
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
} else {
editor_element
}
}
pub fn update_voice_transcription_options(
&mut self,
options: VoiceTranscriptionOptions,
ctx: &mut ViewContext<Self>,
) {
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
return;
}
log::debug!("update_voice_transcription_options: {options:?}");
self.voice_transcription_options = options;
if !self.voice_transcription_options.is_enabled() {
self.stop_voice_input(true, ctx);
}
ctx.notify();
}
pub(super) fn voice_options(ctx: &mut ViewContext<Self>) -> VoiceTranscriptionOptions {
let ai_settings_handle = AISettings::handle(ctx);
if ai_settings_handle.as_ref(ctx).is_voice_input_enabled(ctx) {
VoiceTranscriptionOptions::Enabled { show_button: false }
} else {
VoiceTranscriptionOptions::Disabled
}
}
pub(super) fn stop_voice_input(
&mut self,
cancel_transcription: bool,
ctx: &mut ViewContext<Self>,
) {
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
return;
}
let voice_input = voice_input::VoiceInput::handle(ctx);
if voice_input.as_ref(ctx).is_listening() {
log::debug!("Stopping voice input, cancelling transcription: {cancel_transcription}");
voice_input.update(ctx, |voice_input, ctx| {
if cancel_transcription {
voice_input.abort_listening();
} else if let Err(e) = voice_input.stop_listening(ctx) {
log::error!("Failed to stop voice input: {e:?}");
}
});
}
if cancel_transcription {
self.stop_transcribing_voice_input(ctx);
}
ctx.notify();
}
pub(super) fn stop_transcribing_voice_input(&mut self, ctx: &mut ViewContext<Self>) {
VoiceInput::handle(ctx).update(ctx, |voice, _| voice.set_transcribing_active(false));
if let VoiceInputState::Transcribing { handle, .. } = &self.voice_input_state {
log::debug!("Aborting voice input transcription");
handle.abort();
}
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
ctx.notify();
}
fn voice_error_toast(&mut self, message: &str, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::error(message.to_string());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
pub fn toggle_voice_input(
&mut self,
source: &voice_input::VoiceInputToggledFrom,
ctx: &mut ViewContext<Self>,
) -> bool {
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
return false;
}
if !matches!(
Self::voice_options(ctx),
VoiceTranscriptionOptions::Enabled { .. }
) {
return false;
}
log::debug!(
"Toggling voice input from {:?} for current state: {:?}",
source,
self.voice_input_state
);
match *source {
voice_input::VoiceInputToggledFrom::Button => {
// Allow button clicks to focus and start/stop voice input.
ctx.focus_self();
}
voice_input::VoiceInputToggledFrom::Key { state } => {
// For keypresses, only start voice input if the editor is focused.
// Note, stopping voice input via keypress is handled in a global handler.
if !self.focused {
return false;
}
// If the keypress is not valid in the current state, we ignore it.
match &self.voice_input_state {
// For example, the user could press Fn in a different app, then switch focus
// to Warp and let it go - we should NOT activate voice input in this case.
VoiceInputState::Stopped => {
if matches!(state, warpui::event::KeyState::Released) {
return false;
}
}
// Note that in reality, this case is unreachable because we stop voice input
// if the user is not focused on Warp (since we lose the ability to listen to modifier
// key events). Thus, the user cannot enter a state where we're listening for voice input
// but the key is not held already.
VoiceInputState::Listening => {
if matches!(state, warpui::event::KeyState::Pressed) {
return false;
}
}
_ => {}
}
}
}
match &self.voice_input_state {
VoiceInputState::Stopped => {
if !self.voice_transcription_options.is_enabled() {
return false;
}
if !crate::ai::AIRequestUsageModel::handle(ctx)
.as_ref(ctx)
.can_request_voice()
{
self.voice_error_toast(super::VOICE_LIMIT_HIT_TOAST_TEXT, ctx);
return false;
}
// We allow toggling voice input from a button click even if the editor is not focused.
if self.focused || matches!(*source, voice_input::VoiceInputToggledFrom::Button) {
// Try to start voice input and get the session
let session_result = voice_input::VoiceInput::handle(ctx)
.update(ctx, |voice_input, ctx| {
voice_input.start_listening(ctx, source.clone())
});
let session = match session_result {
Ok(session) => session,
Err(e) => {
match e {
StartListeningError::AccessDenied => {
Self::show_microphone_access_toast(ctx);
}
_ => {
log::error!("Failed to start voice input: {e:?}");
}
}
ctx.notify();
return false;
}
};
// Immediately transition to Listening state
self.set_voice_input_state(VoiceInputState::Listening, ctx);
// Send telemetry for start
let is_udi_enabled = crate::settings::InputSettings::handle(ctx)
.as_ref(ctx)
.is_universal_developer_input_enabled(ctx);
let current_input_mode = if self.is_ai_input {
InputType::AI
} else {
InputType::Shell
};
send_telemetry_from_ctx!(
TelemetryEvent::VoiceInputUsed {
action: "start".to_string(),
session_duration_ms: None,
is_udi_enabled,
current_input_mode,
},
ctx
);
// Spawn future to await the session result
ctx.spawn(
async move { session.await_result().await },
Self::handle_voice_session_result,
);
if matches!(*source, voice_input::VoiceInputToggledFrom::Button) {
// If the user hasn't explicitly interacted with voice yet, show first-time toast.
let window_id = ctx.window_id();
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Some(toggle_key) = settings.maybe_setup_first_time_voice(ctx) {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = crate::view_components::DismissibleToast::success(
format!(
"Voice input is enabled. You can also press and hold the `{}` key to activate voice input (configure in Settings > AI > Voice)",
toggle_key.display_name()
)
.to_string(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
});
}
ctx.notify();
return true;
}
}
VoiceInputState::Listening => {
self.stop_voice_input(false, ctx);
}
VoiceInputState::Transcribing { .. } => {
// Do nothing, we're already transcribing. We don't allow switching states while this is happening.
// TODO(zach): We may want to show some sort of progress indicator when in this state.
}
}
ctx.notify();
false
}
fn show_microphone_access_toast(ctx: &mut ViewContext<Self>) {
let active_window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
let mut toast = crate::view_components::DismissibleToast::error(String::from(
"Failed to start voice input (you may need to enable Microphone access)",
));
// Set an id so the toast is shown at most once.
toast = toast.with_object_id(MICROPHONE_ACCESS_ERROR_ID.to_string());
toast_stack.add_ephemeral_toast(toast, active_window_id, ctx);
});
}
fn set_voice_input_state(
&mut self,
voice_input_state: VoiceInputState,
ctx: &mut ViewContext<Self>,
) {
let was_active = self.is_voice_input_active();
let is_listening = matches!(voice_input_state, VoiceInputState::Listening);
let is_transcribing = matches!(voice_input_state, VoiceInputState::Transcribing { .. });
let will_be_active = matches!(
voice_input_state,
VoiceInputState::Listening | VoiceInputState::Transcribing { .. }
);
if !was_active && will_be_active {
// Lock before marking active, so set_interaction_state applies normally.
self.interaction_state_before_voice = Some(self.interaction_state(ctx));
self.set_interaction_state(super::InteractionState::Selectable, ctx);
self.voice_input_state = voice_input_state;
} else if was_active && !will_be_active {
// Mark inactive before restoring, so set_interaction_state applies normally.
self.voice_input_state = voice_input_state;
if let Some(state) = self.interaction_state_before_voice.take() {
self.set_interaction_state(state, ctx);
}
} else {
// Transition between active states (e.g. Listening → Transcribing).
self.voice_input_state = voice_input_state;
}
ctx.emit(super::Event::VoiceStateUpdated {
is_listening,
is_transcribing,
});
}
/// Handles the result of a voice recording session.
/// This is called when the VoiceSession future resolves.
pub(super) fn handle_voice_session_result(
&mut self,
result: VoiceSessionResult,
ctx: &mut ViewContext<Self>,
) {
if !UserWorkspaces::handle(ctx).as_ref(ctx).is_voice_enabled() {
return;
}
let is_udi_enabled = crate::settings::InputSettings::handle(ctx)
.as_ref(ctx)
.is_universal_developer_input_enabled(ctx);
let current_input_mode = if self.is_ai_input {
InputType::AI
} else {
InputType::Shell
};
match result {
VoiceSessionResult::Audio {
wav_base64,
session_duration_ms,
} => {
send_telemetry_from_ctx!(
TelemetryEvent::VoiceInputUsed {
action: "stop".to_string(),
session_duration_ms: Some(session_duration_ms),
is_udi_enabled,
current_input_mode,
},
ctx
);
// Start transcription
let voice_transcriber = VoiceTranscriber::handle(ctx).as_ref(ctx);
if let Some(transcriber) = voice_transcriber.transcriber() {
let transcriber = transcriber.clone();
VoiceInput::handle(ctx).update(ctx, |voice, _| {
voice.set_transcribing_active(true);
});
self.set_voice_input_state(
VoiceInputState::Transcribing {
handle: ctx.spawn(
async move { transcriber.transcribe(wav_base64).await },
Self::apply_transcribed_voice_input,
),
},
ctx,
);
} else {
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
}
}
VoiceSessionResult::Aborted {
session_duration_ms,
} => {
log::info!("Aborted listening for voice input");
send_telemetry_from_ctx!(
TelemetryEvent::VoiceInputUsed {
action: "cancel".to_string(),
session_duration_ms,
is_udi_enabled,
current_input_mode,
},
ctx
);
self.set_voice_input_state(VoiceInputState::Stopped, ctx);
}
}
ctx.notify();
}
fn apply_transcribed_voice_input(
&mut self,
result: Result<String, TranscribeError>,
ctx: &mut ViewContext<Self>,
) {
if !self.voice_transcription_options.is_enabled() {
self.stop_transcribing_voice_input(ctx);
return;
}
self.stop_transcribing_voice_input(ctx);
match result {
Ok(transcribe_response) => {
log::debug!("Transcribed voice input: {transcribe_response:?}");
self.user_insert(&transcribe_response, ctx);
}
Err(e) => match e {
TranscribeError::QuotaLimit => {
self.voice_error_toast(super::VOICE_LIMIT_HIT_TOAST_TEXT, ctx)
}
_ => {
log::error!("Failed to transcribe voice input: {e:?}");
self.voice_error_toast(super::VOICE_ERROR_TOAST_TEXT, ctx)
}
},
}
ctx.notify();
}
fn render_voice_transcription_button_tooltip(
&self,
appearance: &crate::appearance::Appearance,
app: &AppContext,
) -> Box<dyn FnOnce() -> Box<dyn Element>> {
let tooltip_background = appearance.theme().surface_1().into_solid();
let tooltip_text_color = appearance
.theme()
.main_text_color(tooltip_background.into())
.into_solid();
let ui_builder = appearance.ui_builder().clone();
let microphone_access_state = app.microphone_access_state();
let mic_access_denied = matches!(
microphone_access_state,
warpui::platform::MicrophoneAccessState::Restricted
| warpui::platform::MicrophoneAccessState::Denied
);
let modifier_key = AISettings::handle(app).as_ref(app).voice_input_toggle_key;
let tooltip_text = if mic_access_denied {
"Voice transcription is disabled because Microphone access was not granted.".to_string()
} else if modifier_key == VoiceInputToggleKey::None {
"Voice transcription".to_string()
} else {
format!(
"Voice transcription (hold `{}` key)",
modifier_key.display_name().to_lowercase()
)
};
Box::new(move || {
let tool_tip_style = UiComponentStyles {
background: Some(elements::Fill::Solid(tooltip_background)),
font_color: Some(tooltip_text_color),
..Default::default()
};
ui_builder
.tool_tip(tooltip_text)
.with_style(tool_tip_style)
.build()
.finish()
})
}
pub(super) fn render_voice_transcription_button(
&self,
icon_size: f32,
appearance: &crate::appearance::Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let mut button = if voice_input::VoiceInput::handle(app)
.as_ref(app)
.is_listening()
{
icon_button_with_color(
appearance,
icons::Icon::Stop,
true,
self.voice_transcription_button_mouse_handle.clone(),
Fill::Solid(
AnsiColorIdentifier::Red
.to_ansi_color(&appearance.theme().terminal_colors().normal)
.into(),
),
)
} else {
let is_transcribing =
matches!(self.voice_input_state, VoiceInputState::Transcribing { .. });
icon_button(
appearance,
icons::Icon::Microphone,
is_transcribing,
self.voice_transcription_button_mouse_handle.clone(),
)
};
button = button.with_style(UiComponentStyles {
width: Some(icon_size),
height: Some(icon_size),
padding: Some(Coords::uniform(icon_size / 10.)),
..Default::default()
});
if !self.should_show_voice_new_feature_popup(app) {
button = button
.with_tooltip_position(ButtonTooltipPosition::Above)
.with_tooltip(self.render_voice_transcription_button_tooltip(appearance, app));
}
if matches!(self.voice_input_state, VoiceInputState::Transcribing { .. }) {
button = button.disabled();
}
warpui::elements::SavePosition::new(
button
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EditorAction::ToggleVoiceInput(
voice_input::VoiceInputToggledFrom::Button,
));
})
.with_cursor(Cursor::PointingHand)
.finish(),
"voice_transcription_button",
)
.finish()
}
}