Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(×tamp.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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user