Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
use std::{
collections::HashMap,
sync::{Arc, Weak},
};
use sum_tree::SumTree;
use string_offset::CharOffset;
use super::text::BufferText;
#[cfg(test)]
#[path = "anchor_test.rs"]
mod test;
/// Handle to a particular anchor. As long as there is an active handle, the
/// anchor will be kept in sync with text edits. Once all handles are dropped,
/// the anchor is lazily disposed.
#[derive(Debug, Clone, PartialEq)]
pub struct Anchor {
id: AnchorId,
/// Reference to keep this anchor alive. See [`AnchorReference`].
reference: Arc<AnchorReference>,
}
/// Empty type for keeping anchor references alive.
///
/// We use `Arc<AnchorReference>` to implement reference-counting for anchors.
/// External anchor handles ([`Anchor`]) have strong references to their
/// `AnchorReference`, so it lives as long as there is an existing anchor.
/// Internally, we store a `Weak<AnchorReference>`, solely to be able to check
/// the strong reference count and clean up unused anchors.
///
/// ### Why not count references directly?
/// We could implement our own reference counting using an atomic integer. However,
/// in order to pass that atomic integer to callers and create [`Anchor`] handles,
/// we'd have to wrap it in an `Arc` or similar anyways - [`Anchor`] and [`Anchors`]
/// need a safe way to refer to the same memory!
///
/// ### Why not wrap [`AnchorState`] in an [`Arc`]?
/// We could have [`Anchor`]s strongly own their state, while keeping a [`Weak`]
/// reference here. When updating anchors, we'd filter out any that can't
/// be upgraded to a strong reference. However, we still need mutable access to
/// the character offset of each anchor - that's simpler if [`Anchors`] owns
/// all the mutable state. In addition, we want all anchor dereferencing to
/// go through the model, so that the UI framework enforces consistency around
/// when updates are applied. If [`Anchor`]s could be resolved without a model
/// handle, we don't know what state of the world they see.
type AnchorReference = ();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct AnchorId(usize);
#[derive(Debug, Clone, Copy)]
pub struct AnchorUpdate {
pub start: CharOffset,
pub old_character_count: usize,
pub new_character_count: usize,
pub clamp: bool,
}
/// Used to tie-break when an update happens exactly at the position of the anchor.
/// When set to AnchorSide::Left, updates happening at the exact position won't shift
/// the anchor offset. When set to AnchorSide::Right, they will shift the offset.
///
/// For example, there is an anchor at CharOffset(2) with an incoming update update(
/// start=2, old_char_count=0, new_char_count=1). If the anchor has AnchorSide::Left, it
/// will stay at CharOffset(2). If the anchor has AnchorSide::Right, it will shift to CharOffset(3).
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum AnchorSide {
Left,
Right,
}
/// Internal state for a specific anchor.
struct AnchorState {
/// Weak reference to detect when this anchor is no longer strongly
/// referenced and should be removed. See [`AnchorReference`].
live: Weak<AnchorReference>,
/// The current character offset that this anchor points to.
offset: CharOffset,
side: AnchorSide,
}
/// Component of the buffer model that tracks relative anchors into the content.
///
/// Unlike point-in-time character offsets, anchors shift as text is added or
/// removed around them.
pub(crate) struct Anchors {
next_id: usize,
anchors: HashMap<AnchorId, AnchorState>,
}
impl Anchors {
pub fn new() -> Anchors {
Self {
next_id: 0,
anchors: HashMap::new(),
}
}
/// Update an existing anchor to a new offset.
pub fn update_anchor(&mut self, anchor: &Anchor, offset: CharOffset) {
if let Some(anchor) = self.anchors.get_mut(&anchor.id) {
anchor.offset = offset;
}
}
/// Create a new anchor starting at the given offset.
pub fn create_anchor(&mut self, offset: CharOffset, side: AnchorSide) -> Anchor {
let id = AnchorId(self.next_id);
self.next_id = self.next_id.wrapping_add(1);
let reference = Arc::new(());
self.anchors.insert(
id,
AnchorState {
live: Arc::downgrade(&reference),
offset,
side,
},
);
Anchor { id, reference }
}
/// Update all live anchors to reflect replacing `old_character_count`
/// characters at `start_offset` with `new_character_count` characters.
///
/// This will also dispose of any anchors that are no longer live if clamp is false.
/// If clamp is set to true, the anchors in an deleted range will be clamped to the new
/// end instead.
pub fn update(&mut self, update: AnchorUpdate) {
let AnchorUpdate {
start,
old_character_count,
new_character_count,
clamp,
} = update;
let old_end = start + old_character_count;
let new_end = start + new_character_count;
self.anchors.retain(|_, state| {
if !state.is_live() {
return false;
}
// There are 3 cases for an anchor's location relative to an edit:
// 1. It's after the original end location, and needs to be shifted by the edit delta.
// 2. It's within the deleted text, and should be removed.
// 3. It's not affected by the edit (either before it or within overwritten text).
//
// Consider the following net insertion over d..e (ignore spaces):
// a b c|d e|f g h => a b c|x y z|f g h
// ^ ^ ^ => ^ ^ ^
// 1 2 3 => 1 2 3
// * Anchor 1 should not move.
// * Anchor 2 should not move, even though it's now between x..y
// instead of d..e
// * Anchor 3 moves up by 1 character
//
// Likewise, consider a net deletion, going from def to z:
// a b c|d e f|g h i => a b c|z|g h i
// ^ ^ ^ ^ => ^ ^ ^
// 1 2 3 4 => 1 2 4
// * Again, anchor 1 does not move
// * Anchor 2 does not move - it's within the affected range, now after
// z instead of d, but was not deleted.
// * Anchor 3 referred to content that no longer exists at all, so it
// is removed.
// * Anchor 4 moves down by 2 characters, since it's fully after the
// affected range.
if state.offset > old_end
|| (matches!(state.side, AnchorSide::Right) && state.offset == old_end)
{
// Overall, we need to adjust the offset by the difference between
// the old and new lengths: state.offset += new_end - range.end.
// Since we're dealing with unsigned integers, regrouping as
// state.offset = (state.offset + new_end) - range.end avoids
// underflow. It will overflow if state.offset + new_end is
// greater than usize::MAX, but we do not expect that in practice.
state.offset = (state.offset + new_end) - old_end;
true
} else {
// We want to clamp instead of invalidate anchors if:
// 1) Clamp is set to true.
// 2) If an anchor is exactly at the old_end offset and is pegged to the
// left side, we should still retain the anchor.
if clamp && state.offset > new_end
|| (state.offset == old_end
&& state.offset > new_end
&& matches!(state.side, AnchorSide::Left))
{
state.offset = new_end;
return true;
}
// If we're in this branch, the anchor is either unaffected or
// should be removed.
state.offset <= new_end
}
});
}
/// Resolve an anchor to the character offset it currently points to.
pub fn resolve(&self, anchor: &Anchor) -> Option<CharOffset> {
// The anchor may have been removed by an edit. However, we don't need
// to check liveness, because the fact that an anchor exists to call this
// function means that it is live.
self.anchors.get(&anchor.id).map(|state| state.offset)
}
/// Validates all anchors against the content they reference.
pub fn validate(&self, content: &SumTree<BufferText>) {
let content_length: CharOffset = content.extent();
for (id, anchor) in self.anchors.iter() {
if anchor.is_live() {
assert!(
anchor.offset <= content_length,
"{id:?} has offset {}, but buffer length is {content_length}",
anchor.offset
);
}
}
}
}
impl AnchorState {
fn is_live(&self) -> bool {
self.live.strong_count() > 0
}
}
impl Default for Anchors {
fn default() -> Self {
Self::new()
}
}
+263
View File
@@ -0,0 +1,263 @@
use std::cmp::Ordering;
use sum_tree::SumTree;
use warpui::App;
use super::{AnchorSide, Anchors};
use crate::content::{
anchor::{Anchor, AnchorUpdate},
buffer::Buffer,
cursor::BufferSumTree,
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use string_offset::CharOffset;
#[test]
fn test_anchor_cleanup() {
let mut anchors = Anchors::new();
let a = anchors.create_anchor(3.into(), AnchorSide::Right);
let b = anchors.create_anchor(4.into(), AnchorSide::Right);
// Both anchors are live at this point.
assert_eq!(anchors.anchors.len(), 2);
// If an anchor is dropped, it is garbage-collected.
drop(a);
anchors.update(AnchorUpdate {
start: CharOffset::zero(),
old_character_count: 0,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.anchors.len(), 1);
// However, the other anchor should still resolve.
assert_eq!(anchors.resolve(&b), Some(4.into()));
// If an anchor is cloned, the clone keeps it alive.
let b2 = b.clone();
drop(b);
anchors.update(AnchorUpdate {
start: CharOffset::zero(),
old_character_count: 0,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.resolve(&b2), Some(4.into()));
}
#[test]
fn test_insert() {
let mut anchors = Anchors::new();
let before = anchors.create_anchor(3.into(), AnchorSide::Right);
let cursor = anchors.create_anchor(6.into(), AnchorSide::Right);
let cursor_left = anchors.create_anchor(6.into(), AnchorSide::Left);
let after = anchors.create_anchor(9.into(), AnchorSide::Right);
// Simulate typing a character at the cursor.
anchors.update(AnchorUpdate {
start: CharOffset::from(6),
old_character_count: 0,
new_character_count: 1,
clamp: false,
});
// The anchor before the edit is unaffected.
assert_eq!(anchors.resolve(&before), Some(3.into()));
// The anchor _at_ the cursor increases, to be at the new cursor location.
assert_eq!(anchors.resolve(&cursor), Some(7.into()));
// The anchor _at_ the cursor with AnchorSide::Left stays at its old location.
assert_eq!(anchors.resolve(&cursor_left), Some(6.into()));
// The anchor after the cursor is also shifted down.
assert_eq!(anchors.resolve(&after), Some(10.into()));
// We should be able to type more text, and the anchors continue to update.
anchors.update(AnchorUpdate {
start: CharOffset::from(7),
old_character_count: 0,
new_character_count: 3,
clamp: false,
});
assert_eq!(anchors.resolve(&cursor), Some(10.into()));
assert_eq!(anchors.resolve(&after), Some(13.into()));
}
#[test]
fn test_backspace() {
let mut anchors = Anchors::new();
let before = anchors.create_anchor(3.into(), AnchorSide::Right);
let cursor = anchors.create_anchor(6.into(), AnchorSide::Right);
let after = anchors.create_anchor(9.into(), AnchorSide::Right);
// Simulate backspacing at the cursor.
anchors.update(AnchorUpdate {
start: CharOffset::from(5),
old_character_count: 1,
new_character_count: 0,
clamp: false,
});
// The anchor before the edit is unaffected.
assert_eq!(anchors.resolve(&before), Some(3.into()));
// The cursor and the anchor after it both shift by 1.
assert_eq!(anchors.resolve(&cursor), Some(5.into()));
assert_eq!(anchors.resolve(&after), Some(8.into()));
}
#[test]
fn test_invalidate_anchor() {
let mut anchors = Anchors::new();
let inside = anchors.create_anchor(4.into(), AnchorSide::Right);
let outside = anchors.create_anchor(3.into(), AnchorSide::Right);
let outside_anchor_right = anchors.create_anchor(5.into(), AnchorSide::Right);
let outside_anchor_left = anchors.create_anchor(5.into(), AnchorSide::Left);
// If we delete text including one of the anchors, it's invalidated.
anchors.update(AnchorUpdate {
start: CharOffset::from(3),
old_character_count: 2,
new_character_count: 0,
clamp: false,
});
assert_eq!(anchors.resolve(&inside), None);
// However, the anchor just before it is unaffected.
assert_eq!(anchors.resolve(&outside), Some(3.into()));
// The anchor on the right side is updated because it is equal to the old character range.
assert_eq!(anchors.resolve(&outside_anchor_right), Some(3.into()));
// The anchor on the left side should still be valid because it is equal to the old character range.
assert_eq!(anchors.resolve(&outside_anchor_left), Some(3.into()));
// If clamp is set to true, we want to clamp instead of invalidate anchors.
let inside = anchors.create_anchor(4.into(), AnchorSide::Right);
anchors.update(AnchorUpdate {
start: CharOffset::from(3),
old_character_count: 2,
new_character_count: 0,
clamp: true,
});
assert_eq!(anchors.resolve(&inside), Some(3.into()));
}
#[test]
fn test_update_anchor() {
let mut anchors = Anchors::new();
let anchor = anchors.create_anchor(4.into(), AnchorSide::Right);
anchors.update_anchor(&anchor, CharOffset::from(3));
assert_eq!(anchors.resolve(&anchor), Some(3.into()));
}
#[test]
#[should_panic(expected = "AnchorId(2) has offset 5, but buffer length is 4")]
fn test_validate_anchor_out_of_bounds() {
let mut anchors = Anchors::new();
let _valid_anchor = anchors.create_anchor(2.into(), AnchorSide::Right);
// An invalid, but dead, anchor.
let _ = anchors.create_anchor(100.into(), AnchorSide::Right);
let _invalid_anchor = anchors.create_anchor(5.into(), AnchorSide::Right);
let mut tree = SumTree::new();
tree.append_str("abcd");
anchors.validate(&tree);
}
#[test]
fn test_validate_anchors_ok() {
let mut anchors = Anchors::new();
let _valid_anchor = anchors.create_anchor(2.into(), AnchorSide::Right);
// An invalid, but dead, anchor.
let _ = anchors.create_anchor(100.into(), AnchorSide::Right);
let mut tree = SumTree::new();
tree.append_str("abcd");
// This should not panic.
anchors.validate(&tree);
}
#[test]
fn test_anchor_comparison() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
buffer.edit_internal_first_selection(
CharOffset::zero()..CharOffset::zero(),
"some text",
Default::default(),
selection.clone(),
ctx,
);
let first = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(1), AnchorSide::Right)
});
// Anchors should be equal to themselves.
assert_eq!(
first.cmp(&first.clone(), selection.as_ref(ctx)),
Some(Ordering::Equal)
);
// Anchors should be equal to other anchors with the same offset.
let first2 = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(1), AnchorSide::Right)
});
assert_ne!(first.id, first2.id);
assert_eq!(
first.cmp(&first2, selection.as_ref(ctx)),
Some(Ordering::Equal)
);
// Unequal anchors compare by offset.
let second = selection.update(ctx, |selection, _| {
selection.create_anchor(CharOffset::from(5), AnchorSide::Right)
});
assert_eq!(
first.cmp(&second, selection.as_ref(ctx)),
Some(Ordering::Less)
);
assert_eq!(
second.cmp(&first, selection.as_ref(ctx)),
Some(Ordering::Greater)
);
// Invalid anchors do not compare - delete the range containing `second`.
buffer.edit_internal_first_selection(
CharOffset::from(3)..CharOffset::from(7),
"",
Default::default(),
selection.clone(),
ctx,
);
assert_eq!(second.cmp(&first, selection.as_ref(ctx)), None);
assert_eq!(first.cmp(&second, selection.as_ref(ctx)), None);
});
});
}
impl Anchor {
pub fn cmp(&self, other: &Anchor, selection: &BufferSelectionModel) -> Option<Ordering> {
if self.id == other.id {
Some(Ordering::Equal)
} else {
Some(
selection
.resolve_anchor(self)?
.cmp(&selection.resolve_anchor(other)?),
)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+459
View File
@@ -0,0 +1,459 @@
use std::ops::Range;
use arrayvec::ArrayString;
use num_traits::SaturatingSub;
use pathfinder_color::ColorU;
use string_offset::CharOffset;
use sum_tree::{Cursor, Dimension, SeekBias, SumTree};
use super::text::{
BufferSummary, BufferText, ColorMarker, LinkCount, LinkMarker, SyntaxColorId,
TEXT_FRAGMENT_SIZE,
};
/// Cursor that provides utility function to traverse the buffer tree. Note that
/// this is always preferred over traversing buffer directly using a SumTree cursor given
/// the items could have different length (e.g. markers take 0 offset while text fragment
/// could take more than 1 offset).
#[derive(Clone, Debug)]
pub struct BufferCursor<'a, U: Dimension<'a, BufferSummary>> {
cursor: Cursor<'a, BufferText, CharOffset, U>,
offset: CharOffset,
}
impl<'a, U> BufferCursor<'a, U>
where
U: Dimension<'a, BufferSummary>,
{
pub fn new(cursor: Cursor<'a, BufferText, CharOffset, U>) -> Self {
Self {
cursor,
offset: CharOffset::zero(),
}
}
/// The current offset of the cursor.
pub fn offset(&self) -> CharOffset {
self.offset
}
/// Return the BufferText item at the given cursor position.
pub fn item(&self) -> Option<&'a BufferText> {
self.cursor.item()
}
/// Return the char at the given cursor position. This could be none
/// if the cursor is at a style marker. Note that this is different from
/// Self::item which will return the entire text fragment when cursor is on
/// one.
pub fn char(&self) -> Option<char> {
match &self.item() {
Some(BufferText::Text { fragment, .. }) => {
let cursor_offset = *self.cursor.seek_position();
let ix = self.offset - cursor_offset;
fragment.chars().nth(ix.as_usize())
}
Some(BufferText::BlockMarker { .. }) | Some(BufferText::Newline) => Some('\n'),
_ => None,
}
}
/// Move the cursor to the given charoffset and return the char at the cursor position.
/// This could be empty.
pub fn char_at(&mut self, offset: CharOffset) -> Option<char> {
self.seek_to_offset_after_markers(offset);
self.char()
}
pub fn start(&self) -> &U {
self.cursor.start()
}
/// Attempts to move to the next character position. If the next item has
/// zero character offset length, move to that item and keep the current character
/// offset.
///
/// This is different from Self::next as it will only increment the offset and not
/// move the cursor if it is in the middle of a text fragment.
pub fn next_char_position(&mut self) {
let end = self.cursor.end_seek_position();
let next_char_position = self.offset + 1;
if next_char_position >= end {
self.cursor.next();
self.offset = *self.cursor.seek_position();
} else {
self.offset = next_char_position;
}
}
/// Attempts to move to the previous character position. If the previous item has
/// zero character offset length, move to that item and keep the current character
/// offset.
///
/// This is different from Self::prev as it will only decrement the offset and not
/// move the cursor if it is in the middle of a text fragment.
pub fn prev_char_position(&mut self) {
let start = *self.cursor.seek_position();
let prev_char_position = self.offset.saturating_sub(&CharOffset::from(1));
if start > prev_char_position || self.offset == CharOffset::zero() {
self.cursor.prev();
// If we move into a text fragment, this makes sure we are not jumping
// to the start of that fragment.
self.offset = (*self.cursor.seek_position()).max(prev_char_position);
} else {
self.offset = prev_char_position;
}
}
/// Moves directly to the next buffer text item and updates the active offset.
pub fn next(&mut self) {
self.cursor.next();
self.offset = *self.cursor.seek_position();
}
pub fn prev_item(&self) -> Option<&'a BufferText> {
self.cursor.prev_item()
}
/// Place the cursor at the beginning of end before all the zero-width markers.
pub fn seek_to_offset_before_markers(&mut self, end: CharOffset) -> bool {
debug_assert!(end >= self.offset);
let found = self.cursor.seek(&end, SeekBias::Left);
if found && self.cursor.end_seek_position() == end {
self.cursor.next();
}
self.offset = end;
found || end == CharOffset::zero()
}
/// Place the cursor at the beginning of end after all the zero-width markers.
pub fn seek_to_offset_after_markers(&mut self, end: CharOffset) -> bool {
if end < self.offset {
return false;
}
debug_assert!(end >= self.offset);
self.offset = end;
self.cursor.seek(&end, SeekBias::Right)
}
/// Place the cursor at the beginning of end before all the zero-width markers.
/// Return a new SumTree with items from cursor up to but not including end.
pub fn slice_to_offset_before_markers(&mut self, end: CharOffset) -> SumTree<BufferText> {
debug_assert!(end >= self.offset);
let mut new_content = SumTree::new();
// If the current offset is in the middle of a text fragment, split the fragment and push
// the latter half to the SumTree.
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let start_ix = self.offset - *self.cursor.seek_position();
let char_to_take = end - self.offset;
let new_fragment: String = fragment
.chars()
.skip(start_ix.as_usize())
.take(char_to_take.as_usize())
.collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
if end < self.cursor.end_seek_position() {
self.offset = end;
return new_content;
}
self.cursor.next();
}
let sliced = self.cursor.slice(&end, SeekBias::Left);
new_content.push_tree(sliced);
// If the end offset is in the middle of a text fragment, split the fragment and push
// the earlier half to the SumTree.
if self.cursor.end_seek_position() > end {
if let Some(BufferText::Text { fragment, .. }) = self.cursor.item() {
let ix = end - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().take(ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
}
} else if end > *self.cursor.seek_position() && end > CharOffset::zero() {
if let Some(item) = self.cursor.item() {
new_content.push(item.clone());
}
self.cursor.next();
}
self.offset = end;
new_content
}
/// Place the cursor at the beginning of end after all the zero-width markers.
/// Return a new SumTree with items from cursor up to but not including end.
pub fn slice_to_offset_after_markers(&mut self, end: CharOffset) -> SumTree<BufferText> {
debug_assert!(end >= self.offset);
let mut new_content = SumTree::new();
// If the current offset is in the middle of a text fragment, split the fragment and push
// the earlier half to the SumTree.
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let ix = self.offset - *self.cursor.seek_position();
let char_to_take = end - self.offset;
let new_fragment: String = fragment
.chars()
.skip(ix.as_usize())
.take(char_to_take.as_usize())
.collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
if end < self.cursor.end_seek_position() {
self.offset = end;
return new_content;
}
self.cursor.next();
}
new_content.push_tree(self.cursor.slice(&end, SeekBias::Right));
// If the current offset is in the middle of a text fragment, split the fragment and push
// the latter half to the SumTree.
if *self.cursor.seek_position() < end
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let ix = end - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().take(ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
}
self.offset = end;
new_content
}
/// Return a new SumTree with all items after the current cursor.
pub fn suffix(&mut self) -> SumTree<BufferText> {
let mut new_content = SumTree::new();
if self.offset > *self.cursor.seek_position()
&& let Some(BufferText::Text { fragment, .. }) = self.cursor.item()
{
let start_ix = self.offset - *self.cursor.seek_position();
let new_fragment: String = fragment.chars().skip(start_ix.as_usize()).collect();
if !new_fragment.is_empty() {
new_content.append_str(&new_fragment);
}
self.cursor.next();
}
new_content.push_tree(self.cursor.suffix());
new_content
}
}
pub trait BufferSumTree {
/// Replace an item at the given offset with a new BufferText item.
/// The edit happens in-palace.
fn replace_item_at_offset(&mut self, offset: CharOffset, item: BufferText);
/// Renders a debug representation of this SumTree.
fn debug(&self) -> String;
/// Returns the url with the given link count if the link exists.
fn url_at_link_count(&self, link_count: &LinkCount) -> Option<String>;
/// Returns the color with the given color count if there is a decorated syntax color.
fn color_at_color_count(&self, color_count: &SyntaxColorId) -> Option<ColorU>;
/// Iterate over items in the given character range.
fn items_in_range<'a, U: sum_tree::Dimension<'a, BufferSummary>>(
&'a self,
range: Range<CharOffset>,
) -> BoundedCursor<'a, U>;
/// Append a new str to the end of the SumTree. If the last item in the tree is a Text
/// fragment and it has extra byte size left, fill that text fragment first before creating
/// a new one.
fn append_str(&mut self, s: &str);
}
impl BufferSumTree for SumTree<BufferText> {
fn replace_item_at_offset(&mut self, offset: CharOffset, item: BufferText) {
let old_tree = self.clone();
let mut new_tree = SumTree::new();
let cursor = old_tree.cursor::<CharOffset, CharOffset>();
let mut buffer_cursor = BufferCursor::new(cursor);
new_tree.push_tree(buffer_cursor.slice_to_offset_after_markers(offset));
new_tree.push(item);
buffer_cursor.next();
new_tree.push_tree(buffer_cursor.suffix());
drop(buffer_cursor);
*self = new_tree;
}
fn items_in_range<'a, U: sum_tree::Dimension<'a, BufferSummary>>(
&'a self,
range: Range<CharOffset>,
) -> BoundedCursor<'a, U> {
let inner = self.cursor::<CharOffset, U>();
let mut buffer_cursor = BufferCursor::new(inner);
buffer_cursor.seek_to_offset_after_markers(range.start);
BoundedCursor {
inner: buffer_cursor,
end: range.end,
}
}
fn url_at_link_count(&self, link_count: &LinkCount) -> Option<String> {
let mut cursor = self.cursor::<LinkCount, ()>();
cursor.seek(link_count, SeekBias::Left);
match cursor.item() {
Some(BufferText::Link(LinkMarker::Start(url))) => Some(url.clone()),
_ => None,
}
}
fn color_at_color_count(&self, color_count: &SyntaxColorId) -> Option<ColorU> {
let mut cursor = self.cursor::<SyntaxColorId, ()>();
cursor.seek(color_count, SeekBias::Left);
match cursor.item() {
Some(BufferText::Color(ColorMarker::Start(color))) => Some(*color),
_ => None,
}
}
fn debug(&self) -> String {
use std::fmt::Write;
let mut cursor = self.cursor::<(), ()>();
cursor.descend_to_first_item(self, |_| true);
let mut total_string = String::new();
while let Some(item) = cursor.item() {
let _ = write!(&mut total_string, "{item}");
cursor.next();
}
total_string
}
fn append_str(&mut self, s: &str) {
let trailing_newline = s.ends_with('\n');
let mut is_first = true;
let mut new_fragments = Vec::new();
// Split str into lines first. For linebreaks, we need to push BufferText::Newline.
let mut lines = s.lines().peekable();
while let Some(line) = lines.next() {
let mut text = line;
// For the first fragment we are pushing, try to fill up the trailing text fragment if 1) it exists 2) it has extra byte space.
if is_first && !self.is_empty() {
self.update_last(|last_text| {
if let BufferText::Text {
fragment,
char_count,
} = last_text
{
let split_ix = if fragment.len() + text.len() <= TEXT_FRAGMENT_SIZE {
text.len()
} else {
let mut split_ix = TEXT_FRAGMENT_SIZE
.saturating_sub(fragment.len())
.min(text.len());
while !text.is_char_boundary(split_ix) {
split_ix -= 1;
}
split_ix
};
let (suffix, remainder) = text.split_at(split_ix);
fragment.push_str(suffix);
*char_count = fragment.chars().count() as u8;
text = remainder;
}
});
}
is_first = false;
// If there are still remaining text, push it as a new fragment.
while !text.is_empty() {
let mut split_ix = text.len().min(TEXT_FRAGMENT_SIZE);
while !text.is_char_boundary(split_ix) {
split_ix -= 1;
}
let (chunk, remainder) = text.split_at(split_ix);
new_fragments.push(BufferText::Text {
char_count: chunk.chars().count() as u8,
fragment: ArrayString::from(chunk).unwrap(),
});
text = remainder;
}
if lines.peek().is_some() || trailing_newline {
new_fragments.push(BufferText::Newline);
}
}
self.extend(new_fragments);
}
}
/// A [`Cursor`] that limits itself to only consuming a maximum number of characters.
/// Use this as a building block for range-based iteration.
pub struct BoundedCursor<'a, U: Dimension<'a, BufferSummary>> {
inner: BufferCursor<'a, U>,
end: CharOffset,
}
impl<'a, U> BoundedCursor<'a, U>
where
U: sum_tree::Dimension<'a, BufferSummary>,
{
/// Summary at the start of the current cursor position (see [`Cursor::start`]).
pub fn start(&self) -> &U {
self.inner.start()
}
}
impl<'a, U> Iterator for BoundedCursor<'a, U>
where
U: sum_tree::Dimension<'a, BufferSummary>,
{
type Item = &'a BufferText;
fn next(&mut self) -> Option<Self::Item> {
if self.inner.offset() >= self.end {
None
} else {
let item = self.inner.item()?;
self.inner.next();
Some(item)
}
}
}
#[cfg(test)]
#[path = "cursor_test.rs"]
mod tests;
+220
View File
@@ -0,0 +1,220 @@
use sum_tree::SumTree;
use crate::content::text::{BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir};
use string_offset::CharOffset;
use super::{BufferCursor, BufferSumTree};
/// Helper function to count the number of Text fragments in a SumTree
fn count_text_fragments(tree: &SumTree<BufferText>) -> usize {
let mut cursor = tree.cursor::<(), ()>();
cursor.descend_to_first_item(tree, |_| true);
let mut count = 0;
while let Some(item) = cursor.item() {
if matches!(item, BufferText::Text { .. }) {
count += 1;
}
cursor.next();
}
count
}
#[test]
fn test_plain_text_before_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("This is some text");
tree.push(BufferText::Newline);
tree.append_str("New line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text");
assert_eq!(
tree.debug(),
"<text>This is some text\\nNew line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_before_markers(CharOffset::from(3));
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(6));
assert_eq!(new_content.debug(), "is ");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(20));
assert_eq!(new_content.debug(), "is some text\\nN");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(40));
assert_eq!(new_content.debug(), "ew line veryyyyyyyyy");
}
#[test]
fn test_plain_text_after_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("This is some text");
tree.push(BufferText::Newline);
tree.append_str("New line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text");
assert_eq!(
tree.debug(),
"<text>This is some text\\nNew line veryyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy long text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_after_markers(CharOffset::from(3));
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(6));
assert_eq!(new_content.debug(), "is ");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(20));
assert_eq!(new_content.debug(), "is some text\\nN");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(40));
assert_eq!(new_content.debug(), "ew line veryyyyyyyyy");
}
#[test]
fn test_append_str() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.append_str("Som");
tree.append_str("ething");
tree.append_str(" long stringggggggggggggg");
assert_eq!(tree.debug(), "Something long stringggggggggggggg");
}
#[test]
fn test_append_str_merges_with_existing_fragment() {
// Test the bug fix: is_first should be true when appending to allow merging
// with the last text fragment if it has space remaining
let mut tree: SumTree<BufferText> = SumTree::new();
// Add initial content that creates a text fragment with remaining capacity
tree.append_str("Initial");
// Count fragments before second append
let text_fragments_before = count_text_fragments(&tree);
// Append more text - this should merge with the existing fragment if possible
tree.append_str(" text");
// Count fragments after second append
let text_fragments_after = count_text_fragments(&tree);
// The result should be a single merged fragment, not separate ones
assert_eq!(tree.debug(), "Initial text");
assert_eq!(
text_fragments_before, 1,
"Should have 1 fragment before second append"
);
assert_eq!(
text_fragments_after, 1,
"Should still have 1 fragment after merging"
);
// Verify the internal structure by checking we can iterate correctly
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut buffer_cursor = BufferCursor::new(cursor);
assert_eq!(buffer_cursor.char_at(CharOffset::from(0)), Some('I'));
assert_eq!(buffer_cursor.char_at(CharOffset::from(7)), Some(' '));
assert_eq!(buffer_cursor.char_at(CharOffset::from(8)), Some('t'));
assert_eq!(buffer_cursor.char_at(CharOffset::from(11)), Some('t'));
}
#[test]
fn test_append_str_creates_new_fragment_when_full() {
use crate::content::text::TEXT_FRAGMENT_SIZE;
let mut tree: SumTree<BufferText> = SumTree::new();
// Create a text fragment that's at the TEXT_FRAGMENT_SIZE limit
let large_text = "a".repeat(TEXT_FRAGMENT_SIZE);
tree.append_str(&large_text);
let fragments_before = count_text_fragments(&tree);
// Append additional text - this should create a new fragment since the first is full
tree.append_str("extra");
let fragments_after = count_text_fragments(&tree);
// Should create a new fragment since the first one is at capacity
let expected = format!("{large_text}extra");
assert_eq!(tree.debug(), expected);
assert_eq!(fragments_before, 1, "Should have 1 fragment before append");
assert_eq!(
fragments_after, 2,
"Should have 2 fragments after append when first is full"
);
}
#[test]
fn test_styled_text_before_markers() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("Plain text");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("BI");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
tree.append_str("Just Italic");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
tree.append_str("Plain text");
assert_eq!(
tree.debug(),
"<text>Plain text<b_s><i_s>BI<b_e>Just Italic<i_e>Plain text"
);
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
text_cursor.seek_to_offset_before_markers(CharOffset::from(11));
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(13));
assert_eq!(new_content.debug(), "<b_s><i_s>BI<b_e>");
let new_content = text_cursor.slice_to_offset_after_markers(CharOffset::from(17));
assert_eq!(new_content.debug(), "Just");
let new_content = text_cursor.slice_to_offset_before_markers(CharOffset::from(24));
assert_eq!(new_content.debug(), " Italic");
let new_content = text_cursor.suffix();
assert_eq!(new_content.debug(), "<i_e>Plain text");
}
#[test]
fn test_char_at() {
let mut tree: SumTree<BufferText> = SumTree::new();
tree.append_str("Line");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.append_str("String");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
tree.push(BufferText::Newline);
tree.append_str("Next");
assert_eq!(tree.debug(), "Line<b_s>String<b_e>\\nNext");
let cursor = tree.cursor::<CharOffset, CharOffset>();
let mut text_cursor = BufferCursor::new(cursor);
assert_eq!(text_cursor.char_at(CharOffset::from(1)), Some('i'));
assert_eq!(text_cursor.char_at(CharOffset::from(3)), Some('e'));
assert_eq!(text_cursor.char_at(CharOffset::from(4)), Some('S'));
assert_eq!(text_cursor.char_at(CharOffset::from(10)), Some('\n'));
}
+120
View File
@@ -0,0 +1,120 @@
//! Text diff computation for incremental buffer updates.
//!
//! This module provides functionality for computing minimal diffs between two text strings,
//! which is used for auto-reloading files without wiping undo history or disrupting anchors.
use imara_diff::{Algorithm, Diff, InternedInput, Token};
use std::ops::Range;
use string_offset::{ByteOffset, CharOffset};
use super::buffer::{Buffer, ToBufferCharOffset};
/// A computed diff between two strings.
///
/// The edits are represented as byte ranges in the old text and their replacement strings.
/// These can be applied to transform the old text into the new text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextDiff {
/// List of edits: (old_byte_range, new_text)
pub edits: Vec<(Range<usize>, String)>,
}
impl TextDiff {
/// Returns true if this diff represents no changes.
pub fn is_empty(&self) -> bool {
self.edits.is_empty()
}
/// Convert byte-range edits to CharOffset-range edits.
///
/// The buffer uses 1-indexed coordinates (first editable character is at CharOffset(1)).
/// The diff byte offsets are 0-indexed relative to the plain text, so we add 1 to
/// convert to the buffer's 1-indexed system before using to_buffer_char_offset.
pub fn to_char_offset_edits(&self, buffer: &Buffer) -> Vec<(Range<CharOffset>, String)> {
self.edits
.iter()
.map(|(byte_range, new_text)| {
// Add 1 to convert from 0-indexed plain text byte offset to 1-indexed buffer byte offset
let start = ByteOffset::from(byte_range.start + 1).to_buffer_char_offset(buffer);
let end = ByteOffset::from(byte_range.end + 1).to_buffer_char_offset(buffer);
(start..end, new_text.clone())
})
.collect()
}
}
/// Compute a diff between two strings.
///
/// This uses a line-based diff algorithm (Histogram) for efficiency.
/// Returns a list of edits as (byte_range_in_old, replacement_text) pairs.
pub async fn text_diff(old_text: &str, new_text: &str) -> TextDiff {
let input = InternedInput::new(old_text, new_text);
// Yield here to prevent doing more work if the task is aborted.
futures_lite::future::yield_now().await;
// Only compute line-based diff for now. Zed does more fine-grained word-level diffing for smaller hunks
// but I don't think it's worth it for our use case.
let edits = diff_internal(&input, new_text).await;
TextDiff { edits }
}
async fn diff_internal(input: &InternedInput<&str>, new_text: &str) -> Vec<(Range<usize>, String)> {
let mut old_offset = 0;
let mut new_offset = 0;
let mut old_token_ix = 0;
let mut new_token_ix = 0;
let mut edits = Vec::new();
let diff = Diff::compute(Algorithm::Histogram, input);
// Yield here to prevent doing more work if the task is aborted.
futures_lite::future::yield_now().await;
for hunk in diff.hunks() {
// Calculate byte offsets for unchanged tokens before this hunk
old_offset += token_len(
input,
&input.before[old_token_ix as usize..hunk.before.start as usize],
);
new_offset += token_len(
input,
&input.after[new_token_ix as usize..hunk.after.start as usize],
);
// Calculate byte lengths of the changed tokens
let old_len = token_len(
input,
&input.before[hunk.before.start as usize..hunk.before.end as usize],
);
let new_len = token_len(
input,
&input.after[hunk.after.start as usize..hunk.after.end as usize],
);
let old_byte_range = old_offset..old_offset + old_len;
let new_byte_range = new_offset..new_offset + new_len;
old_token_ix = hunk.before.end;
new_token_ix = hunk.after.end;
old_offset = old_byte_range.end;
new_offset = new_byte_range.end;
let replacement = if new_byte_range.is_empty() {
String::new()
} else {
new_text[new_byte_range].to_string()
};
edits.push((old_byte_range, replacement));
}
edits
}
/// Calculate total byte length of a sequence of tokens.
fn token_len(input: &InternedInput<&str>, tokens: &[Token]) -> usize {
tokens
.iter()
.map(|token| input.interner[*token].len())
.sum()
}
File diff suppressed because it is too large Load Diff
+763
View File
@@ -0,0 +1,763 @@
use super::{
BlockLocation, LayOutArgs, layout_mermaid_diagram_block, layout_table_block, layout_text_block,
};
use crate::{
content::{
buffer::{StyledBufferRun, StyledTextBlock},
edit::{ParsedUrl, highlight_urls, resolve_asset_source_relative_to_directory},
mermaid_diagram::{mermaid_asset_source, mermaid_diagram_layout},
text::{BufferBlockStyle, CodeBlockType, TextStylesWithMetadata},
},
render::{
layout::{TextLayout, add_link_to_style_and_font, markdown_inline_to_text_and_style_runs},
model::{BlockItem, test_utils::TEST_STYLES},
},
};
use std::path::Path;
use string_offset::CharOffset;
use warp_core::features::FeatureFlag;
use warpui::{
App, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState},
fonts::{Properties, Style, Weight},
image_cache::ImageType,
text_layout::{LayoutCache, StyleAndFont, TextStyle},
};
#[test]
fn test_highlight_urls() {
let mut test_styled_buffer_runs = vec![
StyledBufferRun {
run: "https:".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "//".to_string(),
text_styles: TextStylesWithMetadata::default().italic(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "google.com".to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(&test_styled_buffer_runs),
[ParsedUrl {
url_range: 0..18,
link: "https://google.com".to_string()
},]
);
test_styled_buffer_runs.extend(vec![
StyledBufferRun {
run: " abc ".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "https://warp.dev".to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::PlainText,
},
]);
assert_eq!(
highlight_urls(&test_styled_buffer_runs),
[
ParsedUrl {
url_range: 0..18,
link: "https://google.com".to_string()
},
ParsedUrl {
url_range: 23..39,
link: "https://warp.dev".to_string()
}
]
);
}
#[test]
fn test_highlight_urls_unicode() {
let test_runs = vec![StyledBufferRun {
run: "This (not https://example.com) is a 🔥 link about a 🇨🇦 🏡:\u{a0}https://warp.dev"
.to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
}];
assert_eq!(
highlight_urls(&test_runs),
[
ParsedUrl {
url_range: 10..29,
link: "https://example.com".to_string()
},
ParsedUrl {
url_range: 57..73,
link: "https://warp.dev".to_string()
}
]
)
}
#[test]
fn test_highlight_incomplete_url() {
// Tests that we can highlight the valid range of a URL that's still being typed.
// URLs can't end in a `.`, so the detector stops at `www`.
let test_runs = vec![StyledBufferRun {
run: "Word https://www. later".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
}];
assert_eq!(
highlight_urls(&test_runs),
[ParsedUrl {
url_range: 5..16,
link: "https://www".to_string()
},]
)
}
#[test]
fn test_links_not_auto_highlighted() {
// Test that links whose tags look like URLs aren't auto-linked, but also that they don't
// prevent auto-linking other URLs.
let runs = &[
StyledBufferRun {
run: "first link is https://warp.dev ".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "http://example.com".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://warp.dev".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: " second is https://google.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(runs),
&[
ParsedUrl {
url_range: 14..30,
link: "https://warp.dev".to_string()
},
ParsedUrl {
url_range: 60..78,
link: "https://google.com".to_string()
}
]
)
}
#[test]
fn test_highlight_url_before_link() {
// Test that a URL right before an actual hyperlink is still highlighted.
let runs = &[
StyledBufferRun {
run: "https://example.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "hyperlink".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://example.com".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "https://warp.dev".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert_eq!(
highlight_urls(runs),
vec![
ParsedUrl {
url_range: 0..19,
link: "https://example.com".to_string()
},
ParsedUrl {
url_range: 28..44,
link: "https://warp.dev".to_string()
}
]
)
}
#[test]
fn test_text_around_link_not_auto_highlighted() {
// Test that text which, without the link in the middle, would be a URL is not auto-linked.
let runs = &[
StyledBufferRun {
run: "ht".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "alink".to_string(),
text_styles: TextStylesWithMetadata::default().link("https://warp.dev".to_string()),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "tps://example.com".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
assert!(highlight_urls(runs).is_empty());
}
#[test]
fn test_layout_partial_url() {
// Regression test for laying out a partially-styled autodetected URL (CLD-871).
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
let runs = vec![
StyledBufferRun {
run: "A link: https://www.".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "example.com".to_string(),
text_styles: TextStylesWithMetadata::default().bold(),
block_style: BufferBlockStyle::PlainText,
},
StyledBufferRun {
run: "/path text".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::PlainText,
},
];
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let mut line = LayOutArgs::new();
line.highlighted_urls = highlight_urls(&runs);
line.next_url_index = 0;
for run in runs.iter() {
line.layout_run(
&text_layout,
run,
&text_layout.paragraph_styles(&BufferBlockStyle::PlainText),
);
}
let family_id = TEST_STYLES.base_text.font_family;
let base_styles =
StyleAndFont::new(family_id, Properties::default(), TextStyle::default());
assert_eq!(&line.text, "A link: https://www.example.com/path text");
assert_eq!(
&line.style_runs,
&[
(0..8, base_styles),
(8..20, add_link_to_style_and_font(base_styles)),
(
20..31,
add_link_to_style_and_font(StyleAndFont::new(
family_id,
Properties::default().weight(Weight::Bold),
TextStyle::default()
))
),
(31..36, add_link_to_style_and_font(base_styles)),
(36..41, base_styles)
]
)
});
})
}
#[test]
fn test_layout_mermaid_block_uses_loaded_svg_aspect_ratio() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let content = "graph TD\nA[Start] --> B[Finish]\n";
let asset_source = mermaid_asset_source(content);
let mermaid_load = app.read(|ctx| {
let asset_cache = AssetCache::as_ref(ctx);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
AssetState::Loaded { .. } => None,
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
AssetState::FailedToLoad(err) => {
panic!("Mermaid asset should load successfully: {err}")
}
}
});
if let Some(future) = mermaid_load {
future.await;
}
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
800.,
);
let block_style = BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
};
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: block_style.clone(),
}],
style: block_style.clone(),
content_length: CharOffset::from(content.chars().count()),
};
let spacing = TEST_STYLES.block_spacings.from_block_style(&block_style);
let mermaid_diagram = mermaid_diagram_layout(content, &text_layout, spacing, ctx);
let (item, _has_trailing_newline) = layout_mermaid_diagram_block(
block,
mermaid_diagram.0,
mermaid_diagram.1,
BlockLocation::Middle,
false,
)
.expect("Mermaid layout should succeed");
let asset_cache = AssetCache::as_ref(ctx);
let svg = match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loaded { data } => match data.as_ref() {
ImageType::Svg { svg } => svg.clone(),
_ => panic!("expected loaded svg asset"),
},
AssetState::Loading { .. } => panic!("Mermaid asset should already be loaded"),
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
AssetState::FailedToLoad(err) => {
panic!("Mermaid asset should load successfully: {err}")
}
};
match &item {
BlockItem::MermaidDiagram {
content_length,
config,
..
} => {
let intrinsic_size = svg.size();
let expected_width = (800.
- TEST_STYLES
.block_spacings
.from_block_style(&block_style)
.x_axis_offset()
.as_f32())
.min(intrinsic_size.width());
let expected_height =
expected_width * intrinsic_size.height() / intrinsic_size.width();
assert_eq!(*content_length, CharOffset::from(content.chars().count()));
assert!((config.width.as_f32() - expected_width).abs() < 0.5);
assert!((config.height.as_f32() - expected_height).abs() < 0.5);
assert!((item.content_height().as_f32() - config.height.as_f32()).abs() < 0.5);
assert_eq!(item.lines(), 1.into());
assert_eq!(item.first_line_height(), config.height.as_f32());
}
item => panic!("expected MermaidDiagram block, got {item:?}"),
}
});
})
}
#[test]
fn test_resolve_asset_source_relative_to_directory_uses_base_directory() {
let asset_source =
resolve_asset_source_relative_to_directory("diagram.png", Some(Path::new("/tmp/session")));
match asset_source {
AssetSource::LocalFile { path } => {
assert_eq!(Path::new(&path), Path::new("/tmp/session/diagram.png"));
}
source => panic!("expected local file asset source, got {source:?}"),
}
}
#[test]
fn test_layout_text_block_uses_rich_table_when_flag_enabled() {
App::test((), |app| async move {
app.read(|ctx| {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let (item, has_trailing_newline) =
layout_text_block(block, &text_layout, BlockLocation::Middle, false)
.expect("table layout should succeed");
assert!(matches!(item, BlockItem::Table(_)));
assert!(!has_trailing_newline);
});
})
}
#[test]
fn test_layout_text_block_uses_plain_text_when_flag_disabled() {
App::test((), |app| async move {
app.read(|ctx| {
let _flag = FeatureFlag::MarkdownTables.override_enabled(false);
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let (item, _has_trailing_newline) =
layout_text_block(block, &text_layout, BlockLocation::Middle, false)
.expect("table layout should succeed");
assert!(matches!(item, BlockItem::Paragraph(_)));
});
})
}
#[test]
fn test_layout_table_block_caches_cell_text_frames() {
App::test((), |app| async move {
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let content = "short\tmuch longer\ncell\trow\n";
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.to_string(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let table = match layout_table_block(
block,
&text_layout,
TEST_STYLES
.block_spacings
.from_block_style(&BufferBlockStyle::table(Vec::new())),
)
.expect("table layout should succeed")
{
BlockItem::Table(table) => table,
item => panic!("expected table block, got {item:?}"),
};
assert_eq!(table.cell_text_frames.len(), 2);
assert_eq!(table.cell_text_frames[0].len(), 2);
assert_eq!(table.cell_text_frames[1].len(), 2);
assert_eq!(table.cell_layouts.len(), 2);
assert_eq!(table.cell_layouts[0].len(), 2);
assert_eq!(table.cell_layouts[1].len(), 2);
assert!(
table.cell_text_frames[0][1].max_width()
<= table.column_widths[1].as_f32() - table.config.style.cell_padding * 2.0
);
});
})
}
#[test]
fn test_layout_table_block_clamps_cell_width_to_max() {
App::test((), |app| async move {
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
// One long cell in the second column that would otherwise blow out the column
// width. The paragraph has no natural break points within the first 500px, so the
// cell must rely on the per-cell max width cap to keep the column size bounded.
let long_content = "word ".repeat(400);
let content = format!("short\t{long_content}\ncell\trow\n");
let block = StyledTextBlock {
block: vec![StyledBufferRun {
run: content.clone(),
text_styles: TextStylesWithMetadata::default(),
block_style: BufferBlockStyle::table(Vec::new()),
}],
style: BufferBlockStyle::table(Vec::new()),
content_length: CharOffset::from(content.chars().count()),
};
let table = match layout_table_block(
block,
&text_layout,
TEST_STYLES
.block_spacings
.from_block_style(&BufferBlockStyle::table(Vec::new())),
)
.expect("table layout should succeed")
{
BlockItem::Table(table) => table,
item => panic!("expected table block, got {item:?}"),
};
let cell_padding = table.config.style.cell_padding;
let expected_max_cell_width = cell_padding * 2.0 + 500.0;
assert!(
table.column_widths[1].as_f32() <= expected_max_cell_width + f32::EPSILON,
"long cell column width {} should be clamped to {}",
table.column_widths[1].as_f32(),
expected_max_cell_width,
);
// The clamped cell frame must be laid out within the clamped column's content
// width so soft-wrap can occur inside the cell at paint time.
let max_content_width =
table.column_widths[1].as_f32() - table.config.style.cell_padding * 2.0;
assert!(
table.cell_text_frames[0][1].max_width() <= max_content_width + f32::EPSILON,
"long cell frame max width {} should fit within clamped content width {}",
table.cell_text_frames[0][1].max_width(),
max_content_width,
);
});
})
}
#[test]
fn test_table_inline_style_runs_apply_header_bold_default() {
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let mut header_style =
text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
header_style.font_weight = Weight::Bold;
let table = crate::content::text::table_from_internal_format_with_inline_markdown(
"Header\tValue\nText\tCell\n",
Vec::new(),
);
let layout_input = markdown_inline_to_text_and_style_runs(
&table.headers[0],
&header_style,
Some(header_style.text_color),
Some(TEST_STYLES.table_style.header_background),
);
assert_eq!(layout_input.text, "Header");
assert!(!layout_input.style_runs.is_empty());
assert!(
layout_input
.style_runs
.iter()
.all(|(_, style)| style.properties.weight == Weight::Bold)
);
});
});
}
#[test]
fn test_table_inline_style_runs_preserve_markdown_cell_styles() {
App::test((), |app| async move {
let layout_cache = LayoutCache::new();
app.read(|ctx| {
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let body_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new()));
let table = crate::content::text::table_from_internal_format_with_inline_markdown(
"Header\tValue\nText\t**Bold** *Italic* [Link](https://warp.dev) `code`\n",
Vec::new(),
);
let layout_input = markdown_inline_to_text_and_style_runs(
&table.rows[0][1],
&body_style,
Some(body_style.text_color),
Some(TEST_STYLES.table_style.cell_background),
);
assert_eq!(layout_input.text, "Bold Italic Link code");
assert_eq!(layout_input.style_runs.len(), 7);
assert_eq!(layout_input.style_runs[0].0, 0..4);
assert_eq!(layout_input.style_runs[0].1.properties.weight, Weight::Bold);
assert_eq!(layout_input.style_runs[2].0, 5..11);
assert_eq!(layout_input.style_runs[2].1.properties.style, Style::Italic);
assert_eq!(layout_input.style_runs[4].0, 12..16);
assert!(
layout_input.style_runs[4]
.1
.style
.foreground_color
.is_some()
);
assert!(layout_input.style_runs[4].1.style.underline_color.is_some());
assert_eq!(layout_input.style_runs[6].0, 17..21);
assert!(
layout_input.style_runs[6]
.1
.style
.background_color
.is_some()
);
});
});
}
#[test]
fn test_layout_code_block_urls() {
// Regression test for laying out URLs in a code block, which contains multiple lines.
App::test((), |app| async move {
let runs = vec![
StyledBufferRun {
run: "curl -o myfile.txt http://example.com/myfile.txt\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
StyledBufferRun {
run: "vim myfile.txt\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
StyledBufferRun {
run: "rsync myfile.txt ssh://user@server.com\n".to_string(),
text_styles: Default::default(),
block_style: BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
},
];
app.read(|ctx| {
let layout_cache = LayoutCache::new();
let text_layout = TextLayout::new(
&layout_cache,
ctx.font_cache().text_layout_system(),
&TEST_STYLES,
f32::MAX,
);
let paragraph_styles = text_layout.paragraph_styles(&BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
});
let family_id = TEST_STYLES.code_text.font_family;
let base_styles =
StyleAndFont::new(family_id, Properties::default(), TextStyle::default());
let mut line = LayOutArgs::new();
line.highlighted_urls = highlight_urls(&runs);
line.next_url_index = 0;
// First, make sure that we detected the URLs correctly.
assert_eq!(
&line.highlighted_urls,
&[
ParsedUrl {
url_range: 19..48,
link: "http://example.com/myfile.txt".to_string()
},
ParsedUrl {
// URL offsets count painted characters, not newlines.
url_range: 79..100,
link: "ssh://user@server.com".to_string()
}
]
);
// Lay out each line of code 1 by 1 to verify the intermediate state.
assert!(line.layout_run(&text_layout, &runs[0], &paragraph_styles));
assert_eq!(
&line.text,
"curl -o myfile.txt http://example.com/myfile.txt"
);
assert_eq!(
&line.style_runs,
&[
(0..19, base_styles),
(19..48, add_link_to_style_and_font(base_styles)),
]
);
line.reset_for_newline();
assert!(line.layout_run(&text_layout, &runs[1], &paragraph_styles));
assert_eq!(&line.text, "vim myfile.txt");
assert_eq!(&line.style_runs, &[(0..14, base_styles)]);
line.reset_for_newline();
assert!(line.layout_run(&text_layout, &runs[2], &paragraph_styles));
assert_eq!(&line.text, "rsync myfile.txt ssh://user@server.com");
assert_eq!(
&line.style_runs,
&[
(0..17, base_styles),
(17..38, add_link_to_style_and_font(base_styles)),
]
);
});
})
}
+452
View File
@@ -0,0 +1,452 @@
//! Regex-based search through a [`Buffer`].
//!
//! As in other cases, this uses the `regex_automata` [`DFA`] API directly, so that it does not
//! need to copy the buffer into a string (the regex APIs all require an `&[u8]` or equivalent).
//! This has a few downsides, however:
//! * Lazy DFAs do not handle Unicode word boundaries well (see
//! [`regex_automata::hybrid::dfa::Config::unicode_word_boundary`])
//! * We miss out on optimizations that the [`regex_automata::meta::Regex`] matcher makes by
//! choosing between different engines (like avoiding a regex engine entirely for simple
//! literals). In practice, this is likely not a major loss because the literal search
//! optimizations rely on fast substring searches (like highly-optimized platform-specific
//! `memchr` implementations) that we can't use with a non-contiguous buffer.
use std::{borrow::Cow, future::Future};
use anyhow::{Context, bail};
use rangemap::RangeSet;
use regex_automata::{
Anchored, Input, MatchError, MatchKind,
hybrid::{
BuildError, LazyStateID,
dfa::{Cache, DFA},
},
nfa::thompson,
util::syntax::Config,
};
use sum_tree::SumTree;
use string_offset::CharOffset;
use crate::search::RestorableSearchResults;
use super::{
buffer::Buffer,
cursor::BufferCursor,
text::{BufferSummary, BufferText},
};
#[cfg(test)]
#[path = "find_tests.rs"]
mod tests;
/// A match for a text search.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Match {
/// The starting offset of the match (inclusive).
pub start: CharOffset,
/// The ending offset of the match (exclusive).
pub end: CharOffset,
}
/// A compiled, reusable search query.
#[derive(Debug)]
pub struct Query {
/// Box the inner [`Engine`] because it may be large and [`Query`] is moved frequently.
engine: Box<Engine>,
}
/// Results of a search.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResults {
pub matches: Vec<Match>,
}
impl RestorableSearchResults for &SearchResults {
fn valid_matches(&self) -> impl Iterator<Item = (usize, CharOffset)> {
self.matches
.iter()
.enumerate()
.map(|(index, m)| (index, m.start))
}
}
/// Configuration for a search query.
#[derive(Debug, Clone, Copy)]
pub struct SearchConfig<'a> {
query: &'a str,
case_sensitive: bool,
regex: bool,
skip_hidden: bool,
hidden_ranges: Option<&'a RangeSet<CharOffset>>,
}
impl<'a> SearchConfig<'a> {
/// Build configuration to search for the literal `query`. By default, the search is
/// case-sensitive and includes hidden content.
pub fn new(query: &'a str) -> Self {
Self {
query,
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
}
}
/// Build configuration to search for `pattern` as a regular expression. By default, the search
/// is case-sensitive and includes hidden content.
pub fn regex(pattern: &'a str) -> Self {
Self {
query: pattern,
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
}
}
/// Set whether or not this search is case-sensitive.
pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
/// Set whether or not the search query is interpreted as a regular expression.
pub fn with_regex(mut self, regex: bool) -> Self {
self.regex = regex;
self
}
/// Set whether or not to skip hidden content during search. When enabled, text within
/// hidden regions (marked by `BufferTextStyle::Hidden`) will not be searched and will
/// act as match boundaries.
pub fn with_skip_hidden(mut self, skip_hidden: bool) -> Self {
self.skip_hidden = skip_hidden;
self
}
pub fn with_hidden_ranges(mut self, hidden_lines: &'a RangeSet<CharOffset>) -> Self {
self.hidden_ranges = Some(hidden_lines);
self
}
}
#[derive(Debug)]
struct Engine {
// For now, this uses the same DFA approach as the block list. We should
// consider using the meta::Regex implementation instead for better Unicode support (and
// possibly better performance for literal searches).
// Because all matches are constructed up front, we only need forward searching.
forward_dfa: DFA,
forward_cache: Cache,
reverse_dfa: DFA,
reverse_cache: Cache,
skip_hidden: bool,
hidden_ranges: Option<RangeSet<CharOffset>>,
}
impl Engine {
fn new(config: &SearchConfig) -> Result<Self, Box<BuildError>> {
log::trace!("Compiling {config:?}");
let mut builder = DFA::builder();
builder
.syntax(
Config::new()
.case_insensitive(!config.case_sensitive)
// Enable multi-line mode by default - because the buffer always starts with a
// block marker, `^` anchors are otherwise useless.
.multi_line(true),
)
.configure(DFA::config().unicode_word_boundary(true));
let query = if config.regex {
Cow::Borrowed(config.query)
} else {
Cow::Owned(regex_syntax::escape(config.query))
};
let forward_dfa = builder.clone().build(query.as_ref())?;
// See https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L793-L802
// and https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L87-L94
//
// This configuration ensures we find the right match.
let reverse_dfa = builder
.configure(
DFA::config()
.specialize_start_states(false)
.match_kind(MatchKind::All),
)
.thompson(thompson::Config::new().reverse(true))
.build(query.as_ref())?;
let forward_cache = forward_dfa.create_cache();
let reverse_cache = reverse_dfa.create_cache();
Ok(Self {
forward_dfa,
forward_cache,
reverse_dfa,
reverse_cache,
skip_hidden: config.skip_hidden,
hidden_ranges: config.hidden_ranges.cloned(),
})
}
/// Run a search, blocking until it finishes completely. See [`Engine::find`].
#[cfg(test)]
fn find_blocking(
&mut self,
buffer: &SumTree<BufferText>,
buffer_offset: CharOffset,
) -> anyhow::Result<Vec<Match>> {
warpui::r#async::block_on(self.find(buffer, buffer_offset))
}
/// Find all matches for this pattern in the given slice of content.
///
/// All returned offsets are shifted by `buffer_offset` - if the `SumTree` doesn't
/// start at the beginning of the buffer, use this to adjust accordingly (for example, to
/// search in a range of text).
///
/// The search will yield to the scheduler periodically so that it may be cancelled.
async fn find(
&mut self,
buffer: &SumTree<BufferText>,
buffer_offset: CharOffset,
) -> anyhow::Result<Vec<Match>> {
let mut results = vec![];
let mut cursor = buffer.cursor::<CharOffset, BufferSummary>();
cursor.descend_to_first_item(buffer, |_| true);
let mut buffer_cursor = BufferCursor::new(cursor);
while let Some(match_end) = self.next_match(&mut buffer_cursor, SearchDirection::Forward)? {
log::trace!("Found match ending at {match_end}");
// The forward DFA reports the _end_ of the match, so we then use the reverse DFA to
// find its start.
let cursor = buffer.cursor::<CharOffset, BufferSummary>();
buffer_cursor = BufferCursor::new(cursor);
// Seek to the match end location - this might not be the current cursor position,
// because next_match will keep advancing to look for longer matches if possible.
buffer_cursor.seek_to_offset_before_markers(match_end);
// Since match_end is exclusive, move to the prev char position to make sure we are reverse
// iterating from the correct location.
buffer_cursor.prev_char_position();
// Clone the cursor to not lose the search position - the next iteration will resume
// searching after this match.
let mut reverse_cursor = buffer_cursor.clone();
if let Some(match_start) =
self.next_match(&mut reverse_cursor, SearchDirection::Reverse)?
{
// The DFA-reported match end will be the first character _after_ the match - the
// DFA state is always delayed by 1 byte to support look-around operators. For the
// match end, this is what we want, since it's an exclusive end.
// For the start, we add 1 to get the inclusive start offset (adding because the
// start is found with a DFA moving backwards).
results.push(Match {
start: match_start + buffer_offset + 1,
end: match_end + buffer_offset,
});
log::trace!("Match started at {}", match_start + 1);
} else {
log::warn!("Forward DFA found a match end, but reverse DFA did not find a start");
}
// Because we seeked to the left of the match end, move to the next item after the
// match to prevent an infinite loop.
buffer_cursor.next_char_position();
// Individual calls to `next_match` should be fairly fast (barring a pathologically
// slow regular expression), but searching a buffer with many matches could still be
// slow. As a rough heuristic, we yield every 1000 matches so that the future can be
// meaningfully cancelled. If this is insufficient, we could also yield every X
// characters.
if results.len() % 1000 == 1 {
futures_lite::future::yield_now().await;
}
}
Ok(results)
}
/// Find the next pattern match in the given direction, starting at the current `cursor` location.
/// Where variable-length matches are allowed, this will find the longest one (for example, if
/// searching for `a+` in `aaab`, this will return the offset up to `aaa`, even though `a` by
/// itself is also a match).
fn next_match(
&mut self,
cursor: &mut BufferCursor<BufferSummary>,
direction: SearchDirection,
) -> anyhow::Result<Option<CharOffset>> {
let skip_hidden = self.skip_hidden;
let (dfa, cache) = direction.dfa_and_cache(
&self.forward_dfa,
&mut self.forward_cache,
&self.reverse_dfa,
&mut self.reverse_cache,
);
let mut state = direction.start_state(dfa, cache)?;
// We want to find the _longest_ match in a given direction. This means we have to keep
// searching past a match state, until we reach a dead state or end of input.
let mut match_location = None;
'items: while let Some(item) = cursor.item() {
let start_char = cursor.start().text.chars;
log::trace!("Advancing state machine by {item:?} @ {start_char}");
match item {
BufferText::Text { .. } => {
if skip_hidden
&& self
.hidden_ranges
.as_ref()
.map(|hl| hl.contains(&start_char))
.unwrap_or(false)
{
// Hidden text interrupts search. If we've already found a match, return it.
// Otherwise, see if advancing to the EOI state triggers a match (e.g. if the
// last character before the hidden text was a match).
if match_location.is_some() {
break 'items;
} else {
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.start().text.chars);
break 'items;
}
state = direction.start_state(dfa, cache)?;
}
} else if let Some(character) = cursor.char() {
let mut bytes = [0u8; 4];
for byte in character.encode_utf8(&mut bytes).bytes() {
state = dfa
.next_state(cache, state, byte)
.context("Couldn't advance to next state")?;
if state.is_quit() {
bail!("DFA entered quit state");
} else if state.is_dead() {
break 'items;
} else if state.is_match() {
match_location = Some(cursor.offset());
}
}
};
}
BufferText::Newline | BufferText::BlockMarker { .. } => {
state = dfa.next_state(cache, state, b'\n')?;
if state.is_quit() {
bail!("DFA entered quit state");
} else if state.is_dead() {
break 'items;
} else if state.is_match() {
match_location = Some(cursor.start().text.chars);
}
}
BufferText::Marker { .. } | BufferText::Link(_) | BufferText::Color(_) => {
// Inline styling is ignored by search.
}
BufferText::Placeholder { .. } | BufferText::BlockItem { .. } => {
// Non-text / non-interactive items interrupt search. If we've already found a
// match, return it. Otherwise, see if advancing to the EOI state triggers a
// match (e.g. if the last character before the item was a match).
if match_location.is_some() {
break 'items;
} else {
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.start().text.chars);
// We don't need to keep searching in this case, because that would
// allow matching across the boundary.
break 'items;
}
state = direction.start_state(dfa, cache)?;
}
}
}
direction.advance(cursor);
}
state = dfa.next_eoi_state(cache, state)?;
if state.is_match() {
match_location = Some(cursor.offset())
}
Ok(match_location)
}
}
#[derive(Debug, Clone, Copy)]
enum SearchDirection {
Forward,
Reverse,
}
impl SearchDirection {
/// The starting state for searches in this direction.
fn start_state(self, dfa: &DFA, cache: &mut Cache) -> Result<LazyStateID, MatchError> {
match self {
Self::Forward => dfa.start_state_forward(cache, &Input::new("").anchored(Anchored::No)),
// See https://github.com/rust-lang/regex/blob/837fd85e79fac2a4ea64030411b9a4a7b17dfa42/regex-automata/src/hybrid/regex.rs#L483-L488
// For a reverse search, we need to anchor and disable 'earliest'. This makes sure we
// match as much as possible (find the leftmost match) and don't find any matches
// besides the result of the forward search.
Self::Reverse => dfa.start_state_reverse(
cache,
&Input::new("").anchored(Anchored::Yes).earliest(false),
),
}
}
/// Advance the cursor in this direction.
fn advance(self, cursor: &mut BufferCursor<BufferSummary>) {
match self {
Self::Forward => cursor.next_char_position(),
Self::Reverse => {
cursor.prev_char_position();
}
}
}
fn dfa_and_cache<'a>(
self,
forward_dfa: &'a DFA,
forward_cache: &'a mut Cache,
reverse_dfa: &'a DFA,
reverse_cache: &'a mut Cache,
) -> (&'a DFA, &'a mut Cache) {
match self {
Self::Forward => (forward_dfa, forward_cache),
Self::Reverse => (reverse_dfa, reverse_cache),
}
}
}
impl Buffer {
/// Compile a search into a reusable [`Query`].
pub fn prepare_search(&self, config: &SearchConfig) -> anyhow::Result<Query> {
let engine = Box::new(Engine::new(config)?);
Ok(Query { engine })
}
/// Asynchronously run a search from a precompiled query. The search will yield periodically so
/// that it may be cancelled.
///
/// See [`Buffer::prepare_search`].
pub fn search(
&self,
mut query: Query,
) -> impl Future<Output = (Query, anyhow::Result<SearchResults>)> + use<> {
// Cloning the SumTree is cheap, as it wraps an `Arc` of the root node.
let content = self.content.clone();
async move {
let results = query
.engine
.find(&content, CharOffset::zero())
.await
.map(|matches| SearchResults { matches });
(query, results)
}
}
}
+456
View File
@@ -0,0 +1,456 @@
use std::{iter, pin::pin, sync::Once};
use futures_lite::future;
use itertools::Itertools;
use rangemap::RangeSet;
use sum_tree::SumTree;
use warpui::App;
use crate::content::{
buffer::Buffer,
cursor::BufferSumTree,
text::{BufferBlockStyle, BufferText, IndentBehavior},
};
use string_offset::CharOffset;
use super::{Engine, Match, SearchConfig};
#[test]
fn test_search_inline_styles() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"The **first** word, last `word`",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: "t w",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
},
[(9, 12, "t w"), (20, 23, "t w")],
);
});
});
}
#[test]
fn test_search_across_link() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"visit [our website](https://warp.dev) for more",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"visit[\w\s]+site",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 18, "visit our website")],
);
});
});
}
#[test]
fn test_search_longest_match() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"git pull && git log",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"git\s+[\w\-]+",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 9, "git pull"), (13, 20, "git log")],
);
});
});
}
#[test]
fn test_end_of_buffer() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"abc",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_matches(
buffer,
&SearchConfig {
query: r"[a-z]c",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(2, 4, "bc")],
);
assert_matches(
buffer,
&SearchConfig {
query: r"c$",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(3, 4, "c")],
);
assert_matches(
buffer,
&SearchConfig {
query: r"c\b",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(3, 4, "c")],
);
});
});
}
#[test]
fn test_word_boundaries() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"a cat\nlala\npizza\n***\n* A\n* B",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(
buffer.debug(),
"<text>a cat\\nlala\\npizza<hr><ul0>A<ul0>B<text>"
);
assert_matches(
buffer,
&SearchConfig {
query: r"a\b",
case_sensitive: false,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 2, "a"), (10, 11, "a"), (16, 17, "a"), (19, 20, "A")],
);
});
});
}
#[test]
fn test_no_match_across_block_items() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"word\n***\nword",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(buffer.debug(), "<text>word<hr><text>word");
assert_matches(
buffer,
&SearchConfig {
query: "word",
regex: false,
case_sensitive: true,
skip_hidden: false,
hidden_ranges: None,
},
[(1, 5, "word"), (7, 11, "word")],
);
assert_no_matches(
buffer,
&SearchConfig {
query: r"word.*word",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
);
});
});
}
#[test]
fn test_block_boundaries_as_whitespace() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"nee\ndle nee\n1. dle\n```rust\nnee\n```\n```sh\ndle\n```",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(
buffer.debug(),
r"<text>nee\ndle nee<ol0@1>dle<code:Rust>nee<code:Shell>dle<text>"
);
let expected_matches = [
(1, 8, "nee\ndle"),
(9, 16, "nee\ndle"),
(17, 24, "nee\ndle"),
];
assert_matches(
buffer,
&SearchConfig {
query: r"nee\ndle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
expected_matches,
);
// The `s` flag is needed for `.` to match newlines.
assert_no_matches(
buffer,
&SearchConfig {
query: r"nee.dle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
);
assert_matches(
buffer,
&SearchConfig {
query: r"(?s)nee.dle",
case_sensitive: true,
regex: true,
skip_hidden: false,
hidden_ranges: None,
},
expected_matches,
);
});
});
}
#[test]
fn test_anchors() {
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
"word\nsword\nword\nwords\nword",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
assert_eq!(buffer.debug(), r"<text>word\nsword\nword\nwords\nword");
// This fails because of the initial block marker.
assert_no_matches(buffer, &SearchConfig::regex(r"\Aword\z"));
// This succeeds because there's no ending block marker.
assert_matches(buffer, &SearchConfig::regex(r"^word\z"), [(23, 27, "word")]);
assert_matches(
buffer,
&SearchConfig::regex("^word$"),
[(1, 5, "word"), (12, 16, "word"), (23, 27, "word")],
);
assert_matches(
buffer,
&SearchConfig::regex("word$"),
[
(1, 5, "word"),
(7, 11, "word"),
(12, 16, "word"),
(23, 27, "word"),
],
);
assert_matches(
buffer,
&SearchConfig::regex("^word"),
[
(1, 5, "word"),
(12, 16, "word"),
(17, 21, "word"),
(23, 27, "word"),
],
);
});
});
}
#[test]
fn test_skip_hidden_content() {
let mut buffer = SumTree::new();
buffer.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
buffer.append_str("before\nword\nafter");
let mut range_set = RangeSet::new();
range_set.insert(CharOffset::from(8)..CharOffset::from(12));
// With skip_hidden: false, finds text in hidden regions
let mut engine = Engine::new(&SearchConfig {
query: "word",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: Some(&range_set),
})
.unwrap();
let matches = engine.find_blocking(&buffer, CharOffset::zero()).unwrap();
assert_eq!(matches.len(), 1);
// With skip_hidden: true, does not find text in hidden regions
let mut engine = Engine::new(&SearchConfig {
query: "word",
case_sensitive: true,
regex: false,
skip_hidden: true,
hidden_ranges: Some(&range_set),
})
.unwrap();
let matches = engine.find_blocking(&buffer, CharOffset::zero()).unwrap();
assert_eq!(matches.len(), 0);
}
#[test]
fn test_cooperative_search() {
// Manually construct a giant buffer for testing.
let mut buffer = SumTree::new();
buffer.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
for _ in 0..2000 {
buffer.append_str("ab");
}
let mut engine = Engine::new(&SearchConfig {
query: "a",
case_sensitive: true,
regex: false,
skip_hidden: false,
hidden_ranges: None,
})
.unwrap();
let mut search_future = pin!(engine.find(&buffer, CharOffset::zero()));
// With 2000 matches, the search should need 3 polls.
future::block_on(async move {
assert!(future::poll_once(&mut search_future).await.is_none());
assert!(future::poll_once(&mut search_future).await.is_none());
let result = future::poll_once(&mut search_future).await;
match result {
Some(Ok(matches)) => assert_eq!(matches.len(), 2000),
Some(Err(err)) => panic!("Search failed: {err}"),
None => panic!("Expected search to complete"),
}
});
}
fn init_logging() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = env_logger::builder().is_test(true).try_init();
})
}
/// Asserts that running `search` against `buffer` produces the expected matches.
fn assert_matches<'a>(
buffer: &Buffer,
search: &SearchConfig,
expected_matches: impl IntoIterator<Item = impl Into<ExpectedMatch<'a>>>,
) {
init_logging();
let mut engine = Engine::new(search).expect("Could not compile search");
let matches = engine
.find_blocking(&buffer.content, CharOffset::zero())
.expect("Could not run search")
.into_iter()
.map(|m| {
let text = buffer.text_in_range(m.start..m.end).into_string();
(m, text)
})
.collect_vec();
let expected_matches = expected_matches.into_iter().map(Into::into).collect_vec();
assert_eq!(
expected_matches, matches,
"Incorrect search results for {search:?}"
);
}
/// Asserts that `search` has no matches in `buffer`.
fn assert_no_matches(buffer: &Buffer, search: &SearchConfig) {
assert_matches(
buffer,
search,
iter::empty::<(usize, usize, &'static str)>(),
);
}
/// Helper for comparing to expected match results - this is needed because `String` and `&str`
/// aren't directly comparable.
#[derive(Debug)]
struct ExpectedMatch<'a> {
start: CharOffset,
end: CharOffset,
match_text: &'a str,
}
impl<'a> From<(usize, usize, &'a str)> for ExpectedMatch<'a> {
fn from((start, end, match_text): (usize, usize, &'a str)) -> Self {
Self {
start: start.into(),
end: end.into(),
match_text,
}
}
}
impl PartialEq<(Match, String)> for ExpectedMatch<'_> {
fn eq(&self, (other_offsets, other_text): &(Match, String)) -> bool {
self.start == other_offsets.start
&& self.end == other_offsets.end
&& self.match_text == other_text
}
}
@@ -0,0 +1,285 @@
use std::collections::HashMap;
use std::ops::Range;
use rangemap::RangeSet;
use string_offset::CharOffset;
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
use crate::content::edit::EditDelta;
use crate::content::selection_model::BufferSelectionModel;
use crate::content::version::BufferVersion;
use crate::content::{buffer::Buffer, text::LineCount};
use super::anchor::{Anchor, AnchorSide};
/// A model that tracks hidden line ranges independently of the buffer content.
/// This allows multiple editors to have different hidden line states for the same buffer.
///
/// Hidden ranges are stored as anchor pairs that automatically adjust their positions
/// when the buffer content changes.
pub struct HiddenLinesModel {
hidden_ranges: Vec<(Anchor, Anchor)>,
buffer: ModelHandle<Buffer>,
buffer_selections: ModelHandle<BufferSelectionModel>,
version_offsets: HashMap<BufferVersion, RangeSet<CharOffset>>,
}
impl HiddenLinesModel {
pub fn new(
buffer: ModelHandle<Buffer>,
buffer_selections: ModelHandle<BufferSelectionModel>,
) -> Self {
Self {
hidden_ranges: Vec::new(),
buffer,
buffer_selections,
version_offsets: HashMap::new(),
}
}
pub fn set_hidden_lines(&mut self, ranges: RangeSet<LineCount>, ctx: &mut ModelContext<Self>) {
self.hidden_ranges.clear();
let buffer = self.buffer.as_ref(ctx);
// We have to collect here given otherwise we will hold an immutable borrow to Buffer. And below we need a
// mutable reference to the ctx.
let offset_ranges: Vec<Range<CharOffset>> = ranges
.into_iter()
.map(|range| {
let start_offset = buffer.line_start(range.start + LineCount::from(1));
let end_offset = buffer.line_start(range.end + LineCount::from(1));
start_offset..end_offset
})
.collect();
let buffer_version = buffer.buffer_version();
for range in offset_ranges {
if range.start < range.end {
let (start_anchor, end_anchor) =
self.buffer_selections.update(ctx, |selection_model, _| {
let start_anchor = selection_model
.anchors
.create_anchor(range.start, AnchorSide::Right);
// Note that we anchor to the left of the _next line_ after the hidden range.
// This avoids any insert after the hidden range from expanding it.
let end_anchor = selection_model
.anchors
.create_anchor(range.end, AnchorSide::Left);
(start_anchor, end_anchor)
});
self.hidden_ranges.push((start_anchor, end_anchor));
}
}
self.materialize_hidden_range_offsets(buffer_version, ctx);
}
/// Check if the given offset is within a hidden range
pub fn is_hidden(&self, offset: CharOffset, ctx: &AppContext) -> bool {
for (start_anchor, end_anchor) in &self.hidden_ranges {
if let (Some(start), Some(end)) = (
self.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor),
self.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor),
) && offset >= start
&& offset < end
{
return true;
}
}
false
}
/// Check if there are materialized offsets for a given buffer version.
pub fn has_offsets_for_version(&self, version: BufferVersion) -> bool {
self.version_offsets.contains_key(&version)
}
/// For a given content version, check if the incoming offset ranges intersect with any of the
/// hidden ranges.
pub fn range_intersects_with_hidden_range_at_version(
&self,
range: &Range<CharOffset>,
version: BufferVersion,
) -> bool {
// If there is no hidden range set, default to false.
let Some(ranges) = self.version_offsets.get(&version) else {
return false;
};
ranges.overlaps(range)
}
/// Convert anchors (stateful) to offsets (stateless) for a given buffer version. This allows
/// us to maintain a stable hidden line range for each buffer state.
pub fn materialize_hidden_range_offsets(
&mut self,
version: BufferVersion,
ctx: &mut ModelContext<Self>,
) {
let hidden_ranges = self.anchors_to_offsets(ctx);
self.version_offsets.insert(version, hidden_ranges);
}
/// Set the following hidden range to be visible. No-op if the range is not hidden.
/// IMPORTANT: This assumes the line range only overlaps with one hidden range for efficiency.
pub fn set_visible_line_range(
&mut self,
line_range: Range<LineCount>,
ctx: &mut ModelContext<Self>,
) -> Option<EditDelta> {
let starting_visible = self.buffer.as_ref(ctx).line_start(line_range.start);
let buffer_version = self.buffer.as_ref(ctx).buffer_version();
let ending_visible = self
.buffer
.as_ref(ctx)
.line_start(line_range.end + LineCount::from(1));
let mut to_remove = None;
let mut delta = None;
for (idx, (start_anchor, end_anchor)) in self.hidden_ranges.iter().enumerate() {
let Some(start) = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor)
else {
continue;
};
let Some(end) = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor)
else {
continue;
};
// I know this seems inefficient to start...why can't we just invalidate starting_visible to ending_visible?
// This is because of a key constraint in the rendering layer. When we layout the blocks, we expect the boundary
// of what we invalidate to be on the exact boundary of a block. However, with hidden blocks, we collapse them into
// one block in the SumTree. This means we will end up in an invalid state if we try to slice in the middle of that
// giant hidden block. By making sure the invalidation range is min(start, starting_visible)..max(end, ending_visible),
// this works around that constraint. And it shouldn't be much more expensive as we don't layout hidden blocks.
let invalidation_range = start.min(starting_visible)..end.max(ending_visible);
if start >= starting_visible && end <= ending_visible {
to_remove = Some(idx);
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
}
// We can early break since the visible range can only overlap with one hidden range.
if start < starting_visible && end > starting_visible {
self.buffer_selections.update(ctx, |selections, _| {
selections
.anchors
.update_anchor(end_anchor, starting_visible);
});
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
} else if ending_visible > start && end > ending_visible {
self.buffer_selections.update(ctx, |selections, _| {
selections
.anchors
.update_anchor(start_anchor, ending_visible);
});
delta = Some(
self.buffer
.as_ref(ctx)
.invalidate_layout_for_range(invalidation_range),
);
break;
}
}
if let Some(idx) = to_remove {
self.hidden_ranges.remove(idx);
}
self.materialize_hidden_range_offsets(buffer_version, ctx);
delta
}
/// Check if a character offset range contains any hidden sections.
pub fn contains_hidden_section(&self, range: &Range<CharOffset>, ctx: &AppContext) -> bool {
self.is_hidden(range.start, ctx)
|| self.is_hidden(range.end, ctx)
|| self
.hidden_ranges_at_latest(ctx)
.iter()
.any(|hidden_range| {
hidden_range.start < range.end && hidden_range.end > range.start
})
}
/// Check if any selection head is immediately after a hidden section.
pub fn after_hidden_section(&self, ctx: &AppContext) -> bool {
let selections = self.buffer_selections.as_ref(ctx).selection_heads();
let hidden_ranges = self.hidden_ranges_at_latest(ctx);
selections
.iter()
.any(|&head| hidden_ranges.iter().any(|range| range.end == head))
}
/// Check if any selection head is immediately before a hidden section.
pub fn before_hidden_section(&self, ctx: &AppContext) -> bool {
let selections = self.buffer_selections.as_ref(ctx).selection_heads();
let hidden_ranges = self.hidden_ranges_at_latest(ctx);
selections
.iter()
.any(|&head| hidden_ranges.iter().any(|range| range.start == head))
}
pub fn hidden_ranges_at_version(&self, version: BufferVersion) -> RangeSet<CharOffset> {
self.version_offsets
.get(&version)
.cloned()
.unwrap_or_default()
}
pub fn hidden_ranges_at_latest(&self, ctx: &AppContext) -> RangeSet<CharOffset> {
let latest_version = self.buffer.as_ref(ctx).buffer_version();
self.hidden_ranges_at_version(latest_version)
}
/// Get all current hidden ranges as resolved offsets
fn anchors_to_offsets(&self, ctx: &mut ModelContext<Self>) -> RangeSet<CharOffset> {
self.hidden_ranges
.iter()
.filter_map(|(start_anchor, end_anchor)| {
let start = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(start_anchor)?;
let end = self
.buffer_selections
.as_ref(ctx)
.resolve_anchor(end_anchor)?;
if start < end { Some(start..end) } else { None }
})
.collect()
}
}
impl Entity for HiddenLinesModel {
type Event = ();
}
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
use std::sync::Arc;
use markdown_parser::{compute_formatted_text_delta, parse_markdown};
use serde_yaml::Value;
use string_offset::CharOffset;
use vec1::Vec1;
use warpui::{App, ReadModel};
use crate::content::{
buffer::{
Buffer, BufferEditAction, EditOrigin, StyledBlockBoundaryBehavior, tests::TestEmbeddedItem,
},
text::{IndentBehavior, TABLE_BLOCK_MARKDOWN_LANG},
};
use super::MarkdownStyle;
#[test]
fn test_export_normalizes_code_languages() {
let formatted = parse_markdown(
r#"
```JavaScript
console.log("Hello, World");
```
```Rust
println!("Hello, World");
```
```ocaml
print_endline "Hello, World!"
```
"#,
)
.unwrap();
let exported = Buffer::export_to_markdown(
formatted,
None,
MarkdownStyle::Export {
app_context: None,
should_not_escape_markdown_punctuation: false,
},
);
// Exporting should use external code languages.
assert_eq!(
exported,
r#"
```js
console.log("Hello, World");
```
```rust
println!("Hello, World");
```
```ocaml
print_endline "Hello, World!"
```
"#
);
}
#[test]
fn test_mermaid_markdown_round_trip() {
App::test((), |mut app| async move {
let _flag = warp_core::features::FeatureFlag::MarkdownMermaid.override_enabled(true);
let markdown = "```mermaid\ngraph TD\nA --> B\n```\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let internal_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert_eq!(internal_markdown, markdown);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(exported_markdown, markdown);
});
}
#[test]
fn test_export_expands_embeds() {
// This tests styled block for the edge case of querying just the
// leading block item (0..1).
App::test((), |mut app| async move {
let (buffer, _selection) = Buffer::mock_from_markdown(
r#"
```warp-embedded-object
id: embed-123
```
```warp-embedded-object
id: embed-456
ignored: value
```"#,
Some(|mut mapping| match mapping.remove(&"id".into()) {
Some(Value::String(id)) => Some(Arc::new(TestEmbeddedItem { id })),
_ => None,
}),
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let exported = app.read_model(&buffer, |buffer, _| {
buffer.to_markdown(MarkdownStyle::Export {
app_context: None,
should_not_escape_markdown_punctuation: false,
})
});
// Exporting should expand the embedded objects.
assert_eq!(
exported,
r#"
```warp-embedded-object
---
id: embed-123
export: true
```
```warp-embedded-object
---
id: embed-456
export: true
```
"#
);
});
}
#[test]
fn test_table_html_serialization() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nheader 1\theader 2\nvalue 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains(
"<table><thead><tr><th align=\"left\">header 1</th><th align=\"left\">header 2</th></tr></thead><tbody><tr><td align=\"left\">value 1</td><td align=\"left\">value 2</td></tr></tbody></table>"
));
});
}
#[test]
fn test_gfm_table_html_serialization() {
App::test((), |mut app| async move {
let _flag = warp_core::features::FeatureFlag::MarkdownTables.override_enabled(true);
let markdown = "\
| header 1 | header 2 |\n\
| --- | --- |\n\
| value 1 | value 2 |\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains(
"<table><thead><tr><th align=\"left\">header 1</th><th align=\"left\">header 2</th></tr></thead><tbody><tr><td align=\"left\">value 1</td><td align=\"left\">value 2</td></tr></tbody></table>"
));
});
}
#[test]
fn test_apply_formatted_text_delta_append() {
App::test((), |mut app| async move {
let old_markdown = "hello world\n";
let (buffer, selection) = Buffer::mock_from_markdown(
old_markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
// Buffer::mock_from_markdown removes the trailing newline, so add it back.
buffer.update(&mut app, |buffer, ctx| {
let end_offset = buffer.max_charoffset();
let edits =
Vec1::try_from_vec(vec![("\n".to_string(), end_offset..end_offset)]).unwrap();
buffer.update_content(
BufferEditAction::InsertAtCharOffsetRanges { edits: &edits },
EditOrigin::SystemEdit,
selection.clone(),
ctx,
);
});
let (exported, old_formatted) = app.read_model(&buffer, |buffer, _| {
let old_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), old_formatted)
});
assert_eq!(exported, "hello world\n");
let new_markdown = "hello world\n#";
let new_formatted = parse_markdown(new_markdown).unwrap();
let delta = compute_formatted_text_delta(old_formatted, new_formatted.clone());
// Should just be appending a new line
assert_eq!(delta.common_prefix_lines, 1);
// There's a trailing linebreak being replaced
assert_eq!(delta.old_suffix_formatted_text_lines, 1);
assert_eq!(delta.new_suffix.len(), 1);
buffer.update(&mut app, |buffer, ctx| {
buffer.apply_formatted_text_delta(&delta, selection.clone(), ctx);
});
let (exported, formatted_in_buffer) = app.read_model(&buffer, |buffer, _| {
let new_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), new_formatted)
});
assert_eq!(exported, new_markdown);
assert_eq!(new_formatted, formatted_in_buffer);
let new_markdown_2 = "hello world\n# This is a heading";
let new_formatted_2 = parse_markdown(new_markdown_2).unwrap();
let delta_2 = compute_formatted_text_delta(new_formatted, new_formatted_2.clone());
// Should be replacing the # line while keeping the hello world line
assert_eq!(delta_2.common_prefix_lines, 1);
assert_eq!(delta_2.old_suffix_formatted_text_lines, 1);
assert_eq!(delta_2.new_suffix.len(), 1);
buffer.update(&mut app, |buffer, ctx| {
buffer.apply_formatted_text_delta(&delta_2, selection.clone(), ctx);
});
let (exported, formatted_in_buffer) = app.read_model(&buffer, |buffer, _| {
let new_formatted = buffer.range_to_formatted_text(
CharOffset::from(1)..buffer.max_charoffset(),
StyledBlockBoundaryBehavior::Exclusive,
);
(buffer.markdown_unescaped(), new_formatted)
});
// We add a trailing newline
assert_eq!(exported.trim_end(), new_markdown_2);
assert_eq!(new_formatted_2, formatted_in_buffer);
});
}
#[test]
fn test_image_html_serialization() {
App::test((), |mut app| async move {
let markdown = "![Alt text](image.png)\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Image should be serialized as <img src="image.png" alt="Alt text" />
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("<img"));
assert!(html.contains("src=\"image.png\""));
assert!(html.contains("alt=\"Alt text\""));
});
}
#[test]
fn test_multiple_images_html_serialization() {
App::test((), |mut app| async move {
let markdown = "![First](./path/img1.jpg)\n![Second](https://example.com/img2.png)\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Check both images are in the HTML
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("src=\"./path/img1.jpg\""));
assert!(html.contains("alt=\"First\""));
assert!(html.contains("src=\"https://example.com/img2.png\""));
assert!(html.contains("alt=\"Second\""));
});
}
#[test]
fn test_table_markdown_round_trip() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nheader 1\theader 2\nvalue 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let internal_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown());
assert_eq!(internal_markdown, markdown);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(
exported_markdown,
"| header 1 | header 2 |\n| --- | --- |\n| value 1 | value 2 |\n"
);
});
}
#[test]
fn test_table_markdown_export_escapes_pipe_characters() {
App::test((), |mut app| async move {
let markdown = format!(
"```{}\nhead|er 1\theader 2\nvalue | 1\tvalue 2\n```\n",
TABLE_BLOCK_MARKDOWN_LANG
);
let (buffer, _selection) = Buffer::mock_from_markdown(
&markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped());
assert_eq!(
exported_markdown,
"| head\\|er 1 | header 2 |\n| --- | --- |\n| value \\| 1 | value 2 |\n"
);
});
}
#[test]
fn test_image_with_content_html_serialization() {
App::test((), |mut app| async move {
let markdown = "# Header\n\n![Image](test.png)\n\nSome text\n";
let (buffer, _selection) = Buffer::mock_from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
let html = app.read_model(&buffer, |buffer, ctx| {
let range = CharOffset::from(1)..buffer.max_charoffset();
buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx)
});
// Check that header, image, and text are all present
assert!(html.is_some());
let html = html.unwrap();
assert!(html.contains("<h1>"));
assert!(html.contains("Header"));
assert!(html.contains("<img"));
assert!(html.contains("src=\"test.png\""));
assert!(html.contains("Some text"));
});
}
@@ -0,0 +1,91 @@
use std::{
hash::{DefaultHasher, Hash, Hasher},
sync::Arc,
};
use bytes::Bytes;
use mermaid_to_svg::MermaidTheme;
use warpui::{
AppContext, SingletonEntity,
assets::asset_cache::{AssetCache, AssetSource, AssetState, AsyncAssetId, AsyncAssetType},
image_cache::ImageType,
units::{IntoPixels, Pixels},
};
use crate::render::{
layout::TextLayout,
model::{BlockSpacing, ImageBlockConfig},
};
const DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER: f32 = 10.0;
struct MermaidDiagramAsset;
impl AsyncAssetType for MermaidDiagramAsset {}
pub fn mermaid_asset_source(source: &str) -> AssetSource {
let source = source.to_string();
let mut hasher = DefaultHasher::new();
source.hash(&mut hasher);
let id = format!("light:{:x}", hasher.finish());
let fetch_source = source.clone();
AssetSource::Async {
id: AsyncAssetId::new::<MermaidDiagramAsset>(id),
fetch: Arc::new(move || {
let source = fetch_source.clone();
Box::pin(async move {
mermaid_to_svg::render_mermaid_to_svg(&source, Some(&MermaidTheme::light()))
.map(|svg| Bytes::from(svg.into_bytes()))
.map_err(Into::into)
})
}),
}
}
pub fn mermaid_diagram_layout(
source: &str,
layout: &TextLayout,
spacing: BlockSpacing,
app: &AppContext,
) -> (AssetSource, ImageBlockConfig) {
let asset_source = mermaid_asset_source(source);
let max_width = layout.max_width() - spacing.x_axis_offset();
let default_height = layout.rich_text_styles().base_line_height()
* DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER.into_pixels();
let (width, height) =
mermaid_diagram_size(&asset_source, max_width, app).unwrap_or((max_width, default_height));
(
asset_source,
ImageBlockConfig {
width,
height,
spacing,
},
)
}
fn mermaid_diagram_size(
asset_source: &AssetSource,
max_width: Pixels,
app: &AppContext,
) -> Option<(Pixels, Pixels)> {
let asset_cache = AssetCache::as_ref(app);
let AssetState::Loaded { data } = asset_cache.load_asset::<ImageType>(asset_source.clone())
else {
return None;
};
let ImageType::Svg { svg } = data.as_ref() else {
return None;
};
let intrinsic_size = svg.size();
let intrinsic_width = intrinsic_size.width();
let intrinsic_height = intrinsic_size.height();
if intrinsic_width <= 0. || intrinsic_height <= 0. {
return None;
}
let width = Pixels::new(max_width.as_f32().min(intrinsic_width));
let height = Pixels::new(width.as_f32() * intrinsic_height / intrinsic_width);
Some((width, height))
}
+18
View File
@@ -0,0 +1,18 @@
pub mod anchor;
pub mod buffer;
pub mod core;
mod cursor;
pub mod diff;
pub mod edit;
pub mod find;
pub mod hidden_lines_model;
pub mod markdown;
pub mod mermaid_diagram;
pub mod outline;
mod segmentation;
pub mod selection;
pub mod selection_model;
pub mod text;
pub mod undo;
mod validation;
pub mod version;
+93
View File
@@ -0,0 +1,93 @@
use sum_tree::{Cursor, SeekBias};
use crate::content::text::BlockType;
use string_offset::CharOffset;
use super::{
buffer::Buffer,
text::{BlockCount, BufferBlockStyle, BufferText},
};
#[cfg(test)]
#[path = "outline_tests.rs"]
mod tests;
/// Outline of a block within the buffer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockOutline {
/// Offset of the block's start marker.
pub start: CharOffset,
/// Exclusive end of the block - the block marker that ends it.
pub end: CharOffset,
/// Style identifying the kind of block this is.
pub block_type: BlockType,
}
/// Iterator over the blocks within a buffer.
struct BlockOutlines<'a> {
count: BlockCount,
cursor: Cursor<'a, BufferText, BlockCount, CharOffset>,
}
impl Buffer {
/// Outlines the blocks in this buffer.
///
/// This supports quickly finding blocks without the overhead of parsing all style and run
/// information. It's useful for indexing (e.g. to show a table of contents) and building
/// per-block state.
pub fn outline_blocks(&self) -> impl Iterator<Item = BlockOutline> + '_ {
BlockOutlines {
cursor: self.content.cursor(),
count: BlockCount::from(1),
}
}
}
impl Iterator for BlockOutlines<'_> {
type Item = BlockOutline;
fn next(&mut self) -> Option<Self::Item> {
// The start marker for the current block will be at the cursor location where the block
// count changes from `self.count - 1` to `self.count`.
if !self.cursor.seek(&self.count, SeekBias::Left) {
return None;
};
while let Some(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
}) = self.cursor.item()
{
self.count += 1;
let found = self.cursor.seek(&self.count, SeekBias::Left);
if !found {
return None;
}
}
let block_type = match self.cursor.item()? {
BufferText::BlockMarker { marker_type }
if *marker_type != BufferBlockStyle::PlainText =>
{
BlockType::Text(marker_type.clone())
}
BufferText::BlockItem { item_type } => BlockType::Item(item_type.clone()),
other => {
panic!("Invalid cursor state, expected a block-start marker but got {other:?}")
}
};
let start_offset = *self.cursor.start();
// The end of the current block is where the next one begins, so the count increases by 1.
let end_count = self.count + 1;
self.cursor.seek(&end_count, SeekBias::Left);
// We seeked to the start of the next block, so it's where we start on the next pass.
self.count = end_count;
Some(BlockOutline {
start: start_offset,
end: *self.cursor.start(),
block_type,
})
}
}
+225
View File
@@ -0,0 +1,225 @@
use itertools::Itertools;
use crate::content::{
buffer::Buffer,
outline::BlockOutline,
selection_model::BufferSelectionModel,
text::{BlockType, BufferBlockStyle, IndentBehavior, TextStyles},
};
use string_offset::CharOffset;
use warpui::App;
#[test]
fn test_no_blocks() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"regular text",
TextStyles::default(),
selection.clone(),
ctx,
);
assert_eq!(buffer.outline_blocks().count(), 0);
});
});
}
#[test]
fn test_block_at_start() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"BlockText",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(1)..CharOffset::from(6),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<code:Shell>Block<text>Text");
let outline = buffer.outline_blocks().collect_vec();
assert_eq!(
outline,
vec![BlockOutline {
start: CharOffset::from(0),
end: CharOffset::from(6),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}]
)
});
});
}
#[test]
fn test_block_at_end() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"TextBlock",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<text>Text<code:Shell>Block<text>");
let outline = buffer.outline_blocks().collect_vec();
assert_eq!(
outline,
vec![BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}]
)
});
});
}
#[test]
fn test_multiple_blocks() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"textFirsttextSecondtext",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(14)..CharOffset::from(20),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>text<code:Shell>First<text>text<code:Shell>Second<text>text"
);
assert_eq!(
buffer.outline_blocks().collect_vec(),
vec![
BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
},
BlockOutline {
start: CharOffset::from(16),
end: CharOffset::from(23),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}
]
)
});
});
}
#[test]
fn test_adjacent_blocks() {
// This is a regression test for when two blocks of the same kind are adjacent to each other.
// This can occur when inserting a block right before another of the same kind.
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
let _ = buffer.edit_internal_first_selection(
CharOffset::from(1)..CharOffset::from(1),
"textFirstSecondtext",
TextStyles::default(),
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(10)..CharOffset::from(16),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
buffer.block_style_range(
CharOffset::from(5)..CharOffset::from(10),
BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>text<code:Shell>First<code:Shell>Second<text>text"
);
assert_eq!(
buffer.outline_blocks().collect_vec(),
vec![
BlockOutline {
start: CharOffset::from(5),
end: CharOffset::from(11),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
},
BlockOutline {
start: CharOffset::from(11),
end: CharOffset::from(18),
block_type: BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: Default::default()
})
}
]
);
});
});
}
+144
View File
@@ -0,0 +1,144 @@
//! Text segmentation (splitting on words and grapheme clusters) for rich-text buffers.
//! Mostly, this relies on the [`crate::text::word_boundaries`] module by implementing the
//! [`TextBuffer`] API.
use anyhow::anyhow;
use string_offset::CharOffset;
use warpui::text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy};
use super::{
buffer::{Buffer, ToBufferCharOffset, ToBufferPoint},
cursor::BufferCursor,
};
#[cfg(test)]
#[path = "segmentation_tests.rs"]
mod tests;
impl Buffer {
/// Get the offset of the start of the word closest to the given position.
pub fn word_start(&self, offset: CharOffset, policy: &WordBoundariesPolicy) -> CharOffset {
self.word_starts_backward_from_offset_inclusive(offset)
.ok()
.and_then(|word_starts| word_starts.with_policy(policy).next())
.map(|point| point.to_buffer_char_offset(self))
.unwrap_or(offset)
}
/// Get the offset of the end of the word closest to the given position.
pub fn word_end(&self, offset: CharOffset, policy: &WordBoundariesPolicy) -> CharOffset {
self.word_ends_from_offset_inclusive(offset)
.ok()
.and_then(|word_ends| word_ends.with_policy(policy).next())
.map(|point| point.to_buffer_char_offset(self))
.unwrap_or(offset)
}
}
impl TextBuffer for Buffer {
type Chars<'a>
= Chars<'a>
where
Self: 'a;
type CharsReverse<'a>
= Chars<'a>
where
Self: 'a;
fn chars_at(&self, offset: CharOffset) -> anyhow::Result<Self::Chars<'_>> {
Chars::new(self, offset)
}
fn chars_rev_at(&self, offset: CharOffset) -> anyhow::Result<Self::CharsReverse<'_>> {
Chars::new_reversed(self, offset)
}
fn to_point(&self, offset: CharOffset) -> anyhow::Result<Point> {
Ok(offset.to_buffer_point(self))
}
fn to_offset(&self, point: Point) -> anyhow::Result<CharOffset> {
Ok(point.to_buffer_char_offset(self))
}
}
pub struct Chars<'a> {
/// Underlying cursor into the buffer.
cursor: BufferCursor<'a, CharOffset>,
/// Whether we're iterating in the reverse direction.
reversed: bool,
is_first: bool,
}
impl<'a> Chars<'a> {
/// Returns a new char iterator starting at `offset`, or an error if the offset is out of bounds.
fn new(buffer: &'a Buffer, offset: CharOffset) -> anyhow::Result<Self> {
if offset > buffer.max_charoffset() {
return Err(anyhow!("char offset {offset} out of bounds"));
}
let cursor = buffer.content.cursor();
let mut buffer_cursor = BufferCursor::new(cursor);
buffer_cursor.seek_to_offset_before_markers(offset);
Ok(Self {
cursor: buffer_cursor,
reversed: false,
is_first: true,
})
}
/// Returns a new reversed char iterator starting at `offset`, or an error if the offset is out
/// of bounds.
fn new_reversed(buffer: &'a Buffer, offset: CharOffset) -> anyhow::Result<Self> {
if offset > buffer.max_charoffset() {
return Err(anyhow!("char offset {offset} out of bounds"));
}
let cursor = buffer.content.cursor();
let mut buffer_cursor = BufferCursor::new(cursor);
buffer_cursor.seek_to_offset_after_markers(offset);
Ok(Self {
cursor: buffer_cursor,
reversed: true,
is_first: true,
})
}
/// Move the underlying cursor to the next item (in the current direction).
fn advance(&mut self) {
// If this is the first `next` call and the direction is not reversed,
// we don't need to advance the cursor as it is already in the right position.
if self.is_first && !self.reversed {
self.is_first = false;
return;
}
if self.reversed {
self.cursor.prev_char_position();
} else {
self.cursor.next_char_position();
}
}
}
impl Iterator for Chars<'_> {
type Item = char;
fn next(&mut self) -> Option<Self::Item> {
loop {
self.advance();
if self.cursor.item().is_none() {
break None;
}
match self.cursor.char() {
// This treats placeholders as invisible, like style markers. We may want to return
// a fake boundary character instead so that placeholders split word boundaries.
None => continue,
Some(character) => break Some(character),
}
}
}
}
@@ -0,0 +1,231 @@
use itertools::Itertools;
use markdown_parser::parse_markdown;
use string_offset::CharOffset;
use warp_core::features::FeatureFlag;
use crate::content::{
buffer::{Buffer, EditOrigin},
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use warpui::{
App,
text::{TextBuffer, point::Point, word_boundaries::WordBoundariesPolicy},
};
#[test]
fn test_forward_iteration() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("```\nText\n```\n**bold**\nAnd *italic* too.")
.expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<code:Shell>Text<text><b_s>bold<b_e>\\nAnd <i_s>italic<i_e> too."
);
let mut chars = buffer.chars_at(CharOffset::from(1)).expect("Offset valid");
// Block markers are converted to whitespace.
assert_eq!(chars.next(), Some('T'));
assert_eq!(chars.next(), Some('e'));
assert_eq!(chars.next(), Some('x'));
assert_eq!(chars.next(), Some('t'));
assert_eq!(chars.next(), Some('\n'));
// This transparently skips over the style markers.
assert_eq!(chars.next(), Some('b'));
// We should also be able to start from partway through.
let chars = buffer
.chars_at(CharOffset::from(12))
.expect("Offset valid")
.collect_vec();
assert_eq!(
chars,
vec![
'n', 'd', ' ', 'i', 't', 'a', 'l', 'i', 'c', ' ', 't', 'o', 'o', '.'
]
);
});
});
}
#[test]
fn test_table_word_boundaries_include_full_cell_text() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let (buffer, _selection) = Buffer::mock_from_markdown(
"| Hello | Value |\n| --- | --- |\n| World | Cell |\n",
None,
Box::new(|_, _| IndentBehavior::Ignore),
&mut app,
);
buffer.read(&app, |buffer, _| {
let chars = buffer
.chars_at(CharOffset::from(1))
.expect("Offset valid")
.take(12)
.collect_vec();
assert_eq!(
chars,
vec!['H', 'e', 'l', 'l', 'o', '\t', 'V', 'a', 'l', 'u', 'e', '\n']
);
let policy = WordBoundariesPolicy::Default;
let start = buffer.word_start(CharOffset::from(1), &policy);
let end = buffer.word_end(CharOffset::from(1), &policy);
assert_eq!(start, CharOffset::from(1));
assert_eq!(buffer.text_in_range(start..end).into_string(), "Hello");
});
});
}
#[test]
fn test_start_styled() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("*styled* text").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let mut chars = buffer.chars_at(CharOffset::from(1)).expect("Offset valid");
assert_eq!(chars.next(), Some('s'));
assert_eq!(chars.next(), Some('t'));
});
});
}
#[test]
fn test_reverse_iteration() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("some *text*").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let chars = buffer
.chars_rev_at(CharOffset::from(4))
.expect("Offset valid")
.collect_vec();
assert_eq!(chars, vec!['m', 'o', 's', '\n']);
let chars = buffer
.chars_rev_at(CharOffset::from(0))
.expect("Offset valid")
.collect_vec();
assert!(chars.is_empty());
let mut chars = buffer
.chars_rev_at(CharOffset::from(8))
.expect("Offset valid");
assert_eq!(chars.next(), Some('e'));
assert_eq!(chars.next(), Some('t'));
assert_eq!(chars.next(), Some(' '));
assert_eq!(chars.next(), Some('e'));
});
});
}
#[test]
fn test_plain_text_boundaries() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(&mut app, |buffer, ctx| {
buffer.replace_with_formatted_text(
CharOffset::from(0)..CharOffset::from(1),
parse_markdown("this *is* plain\ntext").expect("Markdown should parse"),
EditOrigin::UserInitiated,
selection.clone(),
ctx,
);
let offsets = buffer
.word_starts_from_offset(CharOffset::from(3))
.unwrap()
.collect_vec();
assert_eq!(
offsets,
vec![
Point::new(1, 5),
Point::new(1, 8),
Point::new(2, 0),
Point::new(2, 4)
]
);
let ends_exclusive = buffer
.word_ends_from_offset_exclusive(CharOffset::from(5))
.unwrap()
.collect_vec();
// This should exclude the end of "this".
assert_eq!(
ends_exclusive,
vec![Point::new(1, 7), Point::new(1, 13), Point::new(2, 4)]
);
});
});
}
#[test]
fn test_empty_buffer() {
App::test((), |mut app| async move {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let _selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.read(&app, |buffer, _| {
// Clearly out-of-bounds indices should fail.
assert!(buffer.chars_at(4.into()).is_err());
assert!(buffer.chars_rev_at(4.into()).is_err());
// Starting at 0, on the other hand, produces an empty iterator.
// All buffers implicitly contain a leading block marker. However, when segmenting words, the
// marker should not be included.
let mut chars = buffer.chars_at(CharOffset::zero()).expect("Can start at 0");
assert_eq!(chars.next(), Some('\n'));
let mut chars = buffer
.chars_rev_at(CharOffset::zero())
.expect("Can start at 0");
assert_eq!(chars.next(), None);
let mut words = buffer
.word_starts_from_offset(CharOffset::zero())
.expect("Can start at 0");
// Since the buffer is not truly empty (due to the leading block marker), WordBoundaries::next
// considers the end of the buffer a word boundary.
assert_eq!(words.next(), Some(Point::new(1, 0)));
// Likewise, when moving backwards, the start of the buffer is a word boundary.
let mut words_rev = buffer
.word_starts_backward_from_offset_exclusive(CharOffset::zero())
.expect("Can start at 0");
assert_eq!(words_rev.next(), Some(Point::new(0, 0)));
});
});
}
+154
View File
@@ -0,0 +1,154 @@
use vec1::Vec1;
use string_offset::CharOffset;
use super::anchor::{Anchor, AnchorSide, Anchors};
#[derive(Clone)]
pub struct Selection {
/// A head is where the cursor is and any arrow movement action only modifies the
/// head of a selection.
head: Anchor,
tail: Anchor,
bias: TextStyleBias,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextStyleBias {
InStyle,
#[default]
OutOfStyle,
}
impl Selection {
pub fn new(head: Anchor, tail: Anchor) -> Self {
Self {
head,
tail,
bias: TextStyleBias::OutOfStyle,
}
}
pub fn head(&self) -> &Anchor {
&self.head
}
pub fn bias(&self) -> TextStyleBias {
self.bias
}
pub fn tail(&self) -> &Anchor {
&self.tail
}
pub(super) fn set_head(&mut self, anchors: &mut Anchors, head: CharOffset) {
if anchors.resolve(&self.head).is_some() {
anchors.update_anchor(&self.head, head);
} else {
let anchor = anchors.create_anchor(head, AnchorSide::Right);
self.head = anchor;
}
}
pub(super) fn set_tail(&mut self, anchors: &mut Anchors, tail: CharOffset) {
if anchors.resolve(&self.tail).is_some() {
anchors.update_anchor(&self.tail, tail);
} else {
let anchor = anchors.create_anchor(tail, AnchorSide::Right);
self.tail = anchor;
}
}
pub fn set_bias(&mut self, bias: TextStyleBias) {
self.bias = bias;
}
}
/// All active selections in the editor.
///
/// Create a new selection set with a single selection. Note that there must
/// always be at least one selection in the set.
///
/// let selection_set = SelectionSet::new(selection);
#[derive(Clone)]
pub struct SelectionSet {
selections: Vec1<Selection>,
}
impl SelectionSet {
/// Create a new selection set with a single selection. Note that there must
/// always be at least one selection in the set.
pub fn new(selection: Selection) -> Self {
Self {
selections: Vec1::new(selection),
}
}
/// Return a reference to the first selection that was created.
pub fn first(&self) -> &Selection {
self.selections.first()
}
/// Return a mutable reference to the first selection that was created.
pub fn first_mut(&mut self) -> &mut Selection {
self.selections.first_mut()
}
pub fn last(&self) -> &Selection {
self.selections.last()
}
pub fn last_mut(&mut self) -> &mut Selection {
self.selections.last_mut()
}
/// Add a new selection to the set of selections.
pub fn push(&mut self, selection: Selection) {
self.selections.push(selection);
}
/// Remove all selections except the first one.
pub fn truncate(&mut self) {
self.selections
.truncate(1)
.expect("Truncating to literal 1 cannot fail");
}
pub fn len(&self) -> usize {
self.selections.len()
}
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
/// Map a function over the selections in the set, returning a new Vec1 of the results.
pub fn selection_map<T, F>(&self, f: F) -> Vec1<T>
where
F: Fn(&Selection) -> T,
{
self.selections.mapped_ref(f)
}
pub fn iter(&self) -> impl Iterator<Item = &Selection> {
self.selections.iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Selection> {
self.selections.iter_mut()
}
}
impl From<Vec1<Selection>> for SelectionSet {
fn from(selections: Vec1<Selection>) -> SelectionSet {
SelectionSet { selections }
}
}
impl TryFrom<Vec<Selection>> for SelectionSet {
type Error = vec1::Size0Error;
fn try_from(selections: Vec<Selection>) -> Result<Self, Self::Error> {
Vec1::try_from(selections).map(SelectionSet::from)
}
}
@@ -0,0 +1,386 @@
use std::ops::Range;
use itertools::Itertools;
use string_offset::CharOffset;
use vec1::{Vec1, vec1};
use warpui::{AppContext, Entity, ModelHandle};
use crate::content::{
anchor::{Anchor, AnchorSide, AnchorUpdate, Anchors},
buffer::{Buffer, SelectionOffsets, ToBufferPoint},
selection::{Selection, SelectionSet},
text::{BlockType, TextStylesWithMetadata},
};
/// A snapshot of the selection state. This includes all data reported by [`BufferEvent::SelectionChanged`].
#[derive(PartialEq, Eq)]
pub(super) struct SelectionSnapshot {
/// We must snapshot the resolved selections, rather than their anchors, because anchors are
/// updated in-place and won't directly reflect the update.
pub(super) selections: Vec1<SelectionOffsets>,
pub(super) active_text_styles: TextStylesWithMetadata,
pub(super) active_block_type: BlockType,
}
pub struct BufferSelectionModel {
selections: SelectionSet,
pub(crate) anchors: Anchors,
buffer: ModelHandle<Buffer>,
}
impl BufferSelectionModel {
pub fn new(buffer: ModelHandle<Buffer>) -> Self {
let mut anchors = Anchors::new();
let head_anchor = anchors.create_anchor(CharOffset::from(1), AnchorSide::Right);
let tail_anchor = anchors.create_anchor(CharOffset::from(1), AnchorSide::Right);
let selections = SelectionSet::new(Selection::new(head_anchor, tail_anchor));
Self {
selections,
buffer,
anchors,
}
}
pub fn update_anchors(&mut self, anchor_updates: Vec<AnchorUpdate>) {
for update in anchor_updates {
self.anchors.update(update);
}
}
pub(super) fn truncate(&mut self) {
self.selections.truncate();
}
/// Create a new anchor at the given offset.
pub fn anchor(&mut self, offset: CharOffset, ctx: &AppContext) -> Anchor {
self.anchors
.create_anchor(offset.min(self.buffer.as_ref(ctx).len()), AnchorSide::Right)
}
pub(super) fn create_anchor(&mut self, offset: CharOffset, side: AnchorSide) -> Anchor {
self.anchors.create_anchor(offset, side)
}
/// Resolve an anchor to its current offset.
pub fn resolve_anchor(&self, anchor: &Anchor) -> Option<CharOffset> {
self.anchors.resolve(anchor)
}
/// Resolve an anchor to its 0-based line number in the buffer.
pub fn line_number_from_anchor(&self, anchor: &Anchor, ctx: &AppContext) -> Option<usize> {
let char_offset = self.resolve_anchor(anchor)?;
Some(char_offset.to_buffer_point(self.buffer.as_ref(ctx)).row as usize)
}
// The following two are temporary methods until multiple selections are supported.
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn selection(&self) -> &Selection {
self.selections.first()
}
pub fn selections(&self) -> &SelectionSet {
&self.selections
}
pub fn selections_mut(&mut self) -> &mut SelectionSet {
&mut self.selections
}
/// Returns the index of all lines that have active selection.
pub fn selected_lines(&self, ctx: &AppContext) -> Vec1<usize> {
Vec1::try_from_vec(
self.selections
.iter()
.flat_map(|s| {
let range = self.selection_to_offset_range(s);
let start_idx =
range.start.to_buffer_point(self.buffer.as_ref(ctx)).row as usize;
let end_idx = ((range.end - 1).to_buffer_point(self.buffer.as_ref(ctx)).row
as usize)
.max(start_idx);
start_idx..end_idx + 1
})
.sorted()
.dedup()
.collect_vec(),
)
.expect("Should have more than 1 element")
}
pub fn selection_to_offset_range(&self, selection: &Selection) -> Range<CharOffset> {
// Selection should always be valid when we have single selections.
// TODO(kevin): migrate this when moving to multi-selection.
let head = self
.resolve_anchor(selection.head())
.expect("anchor should exist");
let tail = self
.resolve_anchor(selection.tail())
.expect("anchor should exist");
if head >= tail { tail..head } else { head..tail }
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn selection_to_first_offset_range(&self) -> Range<CharOffset> {
self.selection_to_offset_range(self.selection())
}
pub fn selections_to_offset_ranges(&self) -> Vec1<Range<CharOffset>> {
self.selections
.selection_map(|s| self.selection_to_offset_range(s))
}
fn create_selection(&mut self, head: CharOffset, tail: CharOffset) -> Selection {
Selection::new(
self.create_anchor(head, AnchorSide::Right),
self.create_anchor(tail, AnchorSide::Right),
)
}
pub fn set_selections(&mut self, selections: SelectionSet) {
self.selections = selections;
}
/// Checks for overlapping selections. If any are found, we merge them together.
///
/// Before:
/// | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
/// | S1 | | S3 |
/// | S2 |
/// | S4 |
///
/// After:
/// | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
/// | S1 |
/// | S2 |
pub fn merge_overlapping_selections(&mut self) {
let (overlap_indices, new_ranges) =
Buffer::overlapping_ranges(self.selections_to_offset_ranges().to_vec());
// Return early if there are no overlapping selections.
if overlap_indices.is_empty() {
return;
}
// To determine whether the head of new selections should be before or after the tail,
// we check whether overlapping selections have the head before the tail.
// This seems to work in the common cases.
let forwards_selection = self
.selections
.iter()
.enumerate()
.filter(|(i, _)| overlap_indices.contains(i))
.any(|(_, selection)| self.selection_head(selection) > self.selection_tail(selection));
// Keep any non-overlapping selections.
let mut new_selections: Vec<Selection> = self
.selections
.iter()
.enumerate()
.flat_map(|(i, selection)| {
if !overlap_indices.contains(&i) {
Some(selection.clone())
} else {
None
}
})
.collect();
// Create any new selections from the overlapping selections.
for range in new_ranges {
let new_selection = if forwards_selection {
self.create_selection(range.end, range.start)
} else {
self.create_selection(range.start, range.end)
};
new_selections.push(new_selection);
}
match SelectionSet::try_from(new_selections) {
Ok(selections) => self.selections = selections,
Err(_) => {
log::error!("After removing overlapping selections, there were no selections left!")
}
}
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn first_selection_head(&self) -> CharOffset {
self.resolve_anchor(self.selections.first().head())
.expect("anchor should exist")
}
/// The current location of the selection head.
pub fn selection_head(&self, selection: &Selection) -> CharOffset {
self.resolve_anchor(selection.head())
.expect("anchor should exist")
}
pub fn selection_heads(&self) -> Vec1<CharOffset> {
self.selections
.selection_map(|s| self.resolve_anchor(s.head()).expect("anchor should exist"))
}
// Todo (kc CLD-1018): This should no longer be needed once we move to multi-selection.
pub fn first_selection_tail(&self) -> CharOffset {
self.resolve_anchor(self.selection().tail())
.expect("anchor should exist")
}
/// The current location of the selection tail.
pub fn selection_tail(&self, selection: &Selection) -> CharOffset {
self.resolve_anchor(selection.tail())
.expect("anchor should exist")
}
/// Return all selected ranges.
pub fn selection_offsets(&self) -> Vec1<SelectionOffsets> {
self.selections.selection_map(|s| SelectionOffsets {
head: self.resolve_anchor(s.head()).expect("anchor should exist"),
tail: self.resolve_anchor(s.tail()).expect("anchor should exist"),
})
}
pub fn is_single_selection(&self) -> bool {
self.selections.len() == 1
}
/// Query for the set of text styles that are fully active over the current
/// selection.
pub fn selection_text_styles(&self, ctx: &AppContext) -> TextStylesWithMetadata {
self.selections_to_offset_ranges()
.into_iter()
.map(|range| self.buffer.as_ref(ctx).range_text_styles(range))
.reduce(|style1, style2| style1.mutual_styles(style2))
.expect("At least one selection style should exist")
}
pub fn active_block_type_at_selection(
&self,
selection: &Selection,
ctx: &AppContext,
) -> BlockType {
let range = self.selection_to_offset_range(selection);
self.buffer.as_ref(ctx).block_type_at_point(range.start)
}
/// Check whether the block type for every selection allow formatting.
pub fn all_selections_allow_formatting(&self, ctx: &AppContext) -> bool {
self.selections.iter().all(|selection| {
match self.active_block_type_at_selection(selection, ctx) {
BlockType::Text(block_style) => block_style.allows_formatting(),
// If pasting content directly after a rich block item, we should keep the content's original
// styling.
BlockType::Item(_) => true,
}
})
}
// Todo: kc (CLD1018) Temporary until multiselect is implemented.
pub fn first_selection_is_single_cursor(&self) -> bool {
// Selection should always be valid when we have single selections.
// TODO(kevin): migrate this when moving to multi-selection.
self.first_selection_head() == self.first_selection_tail()
}
pub fn selection_is_single_cursor(&self, selection: &Selection) -> bool {
self.selection_head(selection) == self.selection_tail(selection)
}
/// Return true if all selections are single cursors.
pub fn all_single_cursors(&self) -> bool {
self.selections
.iter()
.all(|s| self.selection_is_single_cursor(s))
}
pub fn cursors_at_line_start(&self, ctx: &AppContext) -> bool {
self.all_single_cursors();
for selection in self.selections.iter() {
let cursor_offset = self.selection_head(selection);
if cursor_offset != self.buffer.as_ref(ctx).containing_line_start(cursor_offset) {
return false;
}
}
true
}
/// Validate the buffer content with this selection model's anchors.
pub fn validate_buffer(&self, ctx: &impl warpui::ModelAsRef) {
self.buffer.as_ref(ctx).validate(&self.anchors);
}
pub(super) fn shift_selections_after_offset(&mut self, offset: CharOffset, delta: usize) {
for selection in self.selections.iter_mut() {
let head_offset = self
.anchors
.resolve(selection.head())
.expect("Anchor should be valid");
if head_offset >= offset && head_offset < offset + delta {
selection.set_head(&mut self.anchors, offset + delta);
}
let tail_offset = self
.anchors
.resolve(selection.tail())
.expect("Anchor should be valid");
if tail_offset >= offset && tail_offset < offset + delta {
selection.set_tail(&mut self.anchors, offset + delta);
}
}
}
pub(super) fn set_single_cursor(&mut self, offset: CharOffset) {
self.set_selection_offsets(vec1![SelectionOffsets {
head: offset,
tail: offset
}]);
}
pub(super) fn set_clamped_selection_head(
&mut self,
selection: &mut Selection,
offset: CharOffset,
) {
selection.set_head(&mut self.anchors, offset);
}
pub(super) fn set_clamped_selection_tail(
&mut self,
selection: &mut Selection,
offset: CharOffset,
) {
selection.set_tail(&mut self.anchors, offset);
}
pub(super) fn update_selection_offsets(&mut self, clamped_selections: Vec1<SelectionOffsets>) {
debug_assert!(
clamped_selections.len() == self.selections().len(),
"To update selection offsets, you must provide the same number of offset pairs as there are currently active selections"
);
for (selection, offsets) in self.selections.iter_mut().zip(clamped_selections.iter()) {
selection.set_head(&mut self.anchors, offsets.head);
selection.set_tail(&mut self.anchors, offsets.tail);
}
}
/// Clear the current selections and replace with this new set of selections.
/// All biases will be cleared.
pub fn set_selection_offsets(&mut self, selections: Vec1<SelectionOffsets>) {
let new_selections = selections.mapped(|offsets| {
let head_anchor = self.create_anchor(offsets.head, AnchorSide::Right);
let tail_anchor = self.create_anchor(offsets.tail, AnchorSide::Right);
Selection::new(head_anchor, tail_anchor)
});
self.set_selections(new_selections.into());
}
}
impl Entity for BufferSelectionModel {
type Event = ();
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
use markdown_parser::CodeBlockText;
use warpui::fonts::Weight;
use markdown_parser::FormattedTable;
use warp_core::features::FeatureFlag;
use super::{
BufferBlockItem, BufferTextStyle, CodeBlockType, MarkdownStyle, TextStyles,
format_image_markdown,
};
#[test]
fn test_text_style_xor() {
// This test makes sure that the `TextStyles` XOR implementations are updated as we add new styles.
for style in enum_iterator::all::<BufferTextStyle>() {
let mut with_style = TextStyles::default();
match style {
BufferTextStyle::Weight(weight) => {
with_style.set_weight(Weight::from_custom_weight(Some(weight)));
}
style => {
if let Some(style_mut) = with_style.style_mut(&style) {
*style_mut = true;
} else {
panic!("Impossible code path -- style {style:?} not handled");
}
}
}
assert!(
(with_style ^ TextStyles::default()).colliding_style(&style),
"Set ^ Unset = Set failed for {style:?}"
);
assert!(
(TextStyles::default() ^ with_style).colliding_style(&style),
"Unset ^ Set = Set failed for {style:?}"
);
assert!(
!(with_style ^ with_style).colliding_style(&style),
"Set ^ Set = Unset failed for {style:?}"
);
assert!(
!(TextStyles::default() ^ TextStyles::default()).colliding_style(&style),
"Unset ^ Unset = Unset failed for {style:?}"
);
let mut editable = with_style;
editable ^= with_style;
assert!(
!editable.colliding_style(&style),
"Set ^= Set -> Unset failed for {style:?}"
);
editable ^= TextStyles::default();
assert!(
!editable.colliding_style(&style),
"Unset ^= Unset -> Unset failed for {style:?}"
);
editable ^= with_style;
assert!(
editable.colliding_style(&style),
"Unset ^= Set -> Set failed for {style:?}"
);
editable ^= TextStyles::default();
assert!(
editable.colliding_style(&style),
"Set ^= Unset -> Set failed for {style:?}"
);
}
}
#[test]
fn test_formatted_table_round_trip() {
let input = "Name\tAge\nAlice\t30\nBob\t25\n";
let table = FormattedTable::from_internal_format(input);
assert_eq!(table.headers.len(), 2);
assert_eq!(table.rows.len(), 2);
assert_eq!(table.to_internal_format(), input);
}
#[test]
fn test_formatted_table_single_column() {
let input = "Header\nValue";
let table = FormattedTable::from_internal_format(input);
assert_eq!(table.headers.len(), 1);
assert_eq!(table.rows.len(), 1);
assert_eq!(table.to_internal_format(), "Header\nValue\n");
}
#[test]
fn test_formatted_table_empty_input() {
let table = FormattedTable::from_internal_format("");
assert!(table.headers.is_empty());
assert!(table.rows.is_empty());
}
#[test]
fn test_mermaid_code_block_type_respects_feature_flag() {
let markdown = CodeBlockText {
lang: "mermaid".to_string(),
code: "graph TD\nA --> B\n".to_string(),
};
let _disabled = FeatureFlag::MarkdownMermaid.override_enabled(false);
assert_eq!(
CodeBlockType::from(&markdown),
CodeBlockType::Code {
lang: "mermaid".to_string(),
}
);
drop(_disabled);
let _enabled = FeatureFlag::MarkdownMermaid.override_enabled(true);
assert_eq!(CodeBlockType::from(&markdown), CodeBlockType::Mermaid);
}
#[test]
fn test_formatted_table_normalize_shape() {
let input = "A\tB\tC\nX";
let mut table = FormattedTable::from_internal_format(input);
assert_eq!(table.rows[0].len(), 1);
table.normalize_shape();
assert_eq!(table.headers.len(), 3);
assert_eq!(table.rows[0].len(), 3);
}
#[test]
fn format_image_markdown_preserves_title() {
// No title -> canonical pre-title form.
assert_eq!(
format_image_markdown("alt", "src.png", None),
"![alt](src.png)"
);
// Empty title is equivalent to no title (product invariant 4).
assert_eq!(
format_image_markdown("alt", "src.png", Some("")),
"![alt](src.png)"
);
// Non-empty title is re-serialized with double quotes.
assert_eq!(
format_image_markdown("alt", "src.png", Some("caption")),
"![alt](src.png \"caption\")"
);
// Literal double quotes in the title are escaped with a backslash so the
// round-trip remains lossless.
assert_eq!(
format_image_markdown("alt", "src.png", Some("a \"quoted\" caption")),
"![alt](src.png \"a \\\"quoted\\\" caption\")"
);
}
#[test]
fn buffer_block_image_as_markdown_preserves_title() {
let untitled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: None,
};
assert_eq!(
&*untitled.as_markdown(MarkdownStyle::Internal),
"![A dog](dog.png)"
);
let titled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex, my dog".to_string()),
};
assert_eq!(
&*titled.as_markdown(MarkdownStyle::Internal),
"![A dog](dog.png \"Rex, my dog\")"
);
}
#[test]
fn buffer_block_image_partial_eq_considers_title() {
let untitled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: None,
};
let titled = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex".to_string()),
};
assert_ne!(untitled, titled);
let titled_again = BufferBlockItem::Image {
alt_text: "A dog".to_string(),
source: "dog.png".to_string(),
title: Some("Rex".to_string()),
};
assert_eq!(titled, titled_again);
}
+342
View File
@@ -0,0 +1,342 @@
use std::{collections::VecDeque, time::Duration};
use instant::Instant;
use warp_util::content_version::ContentVersion;
use crate::render::model::RenderedSelectionSet;
use super::core::{CoreEditorAction, ReplacementRange};
/// Threshold to separate two non-atomic undo items.
const UNDO_REDO_TIMER: Duration = Duration::from_millis(500);
/// Represents one high-level editor action that should be
/// undo/redo in one step. The high-level action could be broken
/// down into a set of smaller core editor actions.
///
/// Note that we also record the selection and replacement range state
/// before and after the editor action since they don't need to be
/// recalculated.
#[derive(Debug, Clone)]
pub struct ReversibleEditorActions {
pub actions: Vec<ReversibleEditorAction>,
pub selections: ReversibleSelectionState,
pub replacement_range: ReplacementRange,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UndoActionType {
// The action is atomic and should not be combined with any other action
// for a batched undo/redo.
Atomic,
// The action is non-atomic and could be undo/redo in a batch.
NonAtomic(NonAtomicType),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum NonAtomicType {
Insert,
Backspace,
}
impl NonAtomicType {
// Whether the change adds or removes content.
fn is_addition(&self) -> bool {
match self {
NonAtomicType::Insert => true,
NonAtomicType::Backspace => false,
}
}
}
/// A collection of editor actions that should be undo/redo together.
struct UndoStackItem {
items: VecDeque<Vec<ReversibleEditorAction>>,
selections: ReversibleSelectionState,
replacement_range: ReplacementRange,
// The version of the content after the original actions were applied.
version: ContentVersion,
}
impl UndoStackItem {
/// Create a new undo stack item.
/// The version of the buffer after the actions were applied is also recorded.
fn new(item: ReversibleEditorActions, version: ContentVersion) -> Self {
let mut new_items = VecDeque::new();
new_items.push_back(item.actions);
Self {
items: new_items,
selections: item.selections,
replacement_range: item.replacement_range,
version,
}
}
fn reverse(&mut self) {
let mut new_items = VecDeque::new();
// We are reversing three things here:
// 1. The batched edit actions' order
// 2. Within each edit action, its core edit actions' order
// 3. Each core edit action
for mut item in self.items.drain(..) {
item.reverse();
for action in &mut item {
std::mem::swap(&mut action.next, &mut action.reverse);
}
new_items.push_front(item);
}
self.items = new_items;
std::mem::swap(&mut self.selections.next, &mut self.selections.reverse);
std::mem::swap(
&mut self.replacement_range.new_range,
&mut self.replacement_range.old_range,
);
}
// Pushing another edit action to the batch.
fn add(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
let is_addition = action.is_addition();
self.items.push_front(item.actions);
self.selections.reverse = item.selections.reverse;
self.version = version;
// This might be a bit confusing. For batched additions, the new range after the undo
// will always be the first edit actions' new range. The old range needs to be
// extended because we are inserting content into the buffer in each action.
//
// For batched deletions, the new ranges' start needs to be moved back with each edit because
// we are removing content from the buffer. The old range will be the range of the last edit
// actions' old range.
if is_addition {
self.replacement_range.old_range.end = self
.replacement_range
.old_range
.end
.max(item.replacement_range.old_range.end);
} else {
self.replacement_range.old_range = item.replacement_range.old_range;
self.replacement_range.new_range.start = self
.replacement_range
.new_range
.start
.min(item.replacement_range.new_range.start);
}
}
pub fn actions(&self) -> Vec<Vec<CoreEditorAction>> {
let mut result = Vec::new();
for item in &self.items {
result.push(item.iter().map(|action| action.next.clone()).collect());
}
result
}
pub fn selection(&self) -> RenderedSelectionSet {
self.selections.next.clone()
}
}
#[derive(Debug, Clone)]
pub struct ReversibleEditorAction {
pub next: CoreEditorAction,
pub reverse: CoreEditorAction,
}
#[derive(Debug, Clone)]
pub struct ReversibleSelectionState {
pub next: RenderedSelectionSet,
pub reverse: RenderedSelectionSet,
}
/// Changes need to be applied to the buffer model with a triggered
/// undo/redo action.
#[derive(Debug)]
pub struct UndoResult {
pub actions: Vec<Vec<CoreEditorAction>>,
pub selection: RenderedSelectionSet,
pub replacement_range: ReplacementRange,
}
#[derive(Debug, Clone)]
pub(super) struct UndoArg {
pub actions: Vec<ReversibleEditorAction>,
pub replacement_range: ReplacementRange,
}
pub(super) struct UndoStack {
stack: VecDeque<UndoStackItem>,
current_index: usize,
capacity: usize,
previous_action_type: Option<NonAtomicType>,
last_edit: Option<Instant>,
// The buffer version after everything is undone.
initial_version: ContentVersion,
}
impl UndoStack {
pub fn new(capacity: usize, initial_version: ContentVersion) -> UndoStack {
Self {
stack: Default::default(),
current_index: 0,
capacity,
previous_action_type: None,
last_edit: None,
initial_version,
}
}
pub fn clear_previous_non_atomic_type(&mut self) {
self.previous_action_type = None;
self.last_edit = None;
}
fn push_undo_item_to_stack(&mut self, item: ReversibleEditorActions, version: ContentVersion) {
let mut stack_size = self.stack.len();
if self.current_index < stack_size {
// We need to clear the redo stack if the user has undone already and then make edits.
self.stack.truncate(self.current_index);
stack_size = self.current_index;
}
if stack_size == self.capacity {
self.initial_version = self
.stack
.pop_front()
.expect("Undo stack is empty after checking size")
.version;
self.current_index -= 1;
}
self.stack.push_back(UndoStackItem::new(item, version));
self.current_index += 1;
}
#[cfg(test)]
pub(super) fn push_non_atomic_edit(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
if self.previous_action_type == Some(action) && self.current_index > 0 {
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.push_undo_item_to_stack(item, version);
}
pub fn push_new_edit(
&mut self,
item: ReversibleEditorActions,
action: UndoActionType,
version: ContentVersion,
) {
// An item should be pushed into the last batch if:
// 1. Action is non-atomic
// 2. Action matches the previous batch's non-atomic type
// 3. The gap between two edits hasn't exceeded threshold
if let UndoActionType::NonAtomic(action) = action {
let elapsed_time_within_limit = match self.last_edit {
Some(timer) => timer.elapsed() < UNDO_REDO_TIMER,
None => false,
};
if self.previous_action_type == Some(action)
&& self.current_index > 0
&& elapsed_time_within_limit
{
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.last_edit = Some(Instant::now());
} else {
self.previous_action_type = None;
self.last_edit = None;
}
self.push_undo_item_to_stack(item, version);
}
pub fn undo(&mut self) -> Option<UndoResult> {
if self.stack.is_empty() || self.current_index == 0 {
return None;
}
self.previous_action_type = None;
self.current_index -= 1;
let undo_item = &self.stack[self.current_index];
let undo_result = UndoResult {
actions: undo_item.actions(),
selection: undo_item.selection(),
replacement_range: undo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
Some(undo_result)
}
pub fn redo(&mut self) -> Option<UndoResult> {
if self.current_index >= self.stack.len() {
return None;
}
let redo_item = &self.stack[self.current_index];
let redo_result = UndoResult {
actions: redo_item.actions(),
selection: redo_item.selection(),
replacement_range: redo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
self.current_index += 1;
Some(redo_result)
}
pub fn reset(&mut self, version: ContentVersion) {
self.stack.clear();
self.current_index = 0;
self.clear_previous_non_atomic_type();
self.initial_version = version;
}
/// Check if the given version matches the current version as reflected in the undo stack.
/// If at the bottom of the stack, check if it's equal to the earliest version of the content
/// that the undo stack was aware of.
/// If the stack is not empty, check if the version matches with the last completed item in the stack.
/// Due to undo/redo moving the `current_index` pointer in the stack, the last completed item is not
/// always the last item on the stack.
pub fn version_match(&self, version: &ContentVersion) -> bool {
if self.current_index == 0 {
return *version == self.initial_version;
}
self.stack[self.current_index - 1].version == *version
}
pub(super) fn set_initial_version(&mut self, version: ContentVersion) {
self.initial_version = version;
}
pub(super) fn is_empty(&self) -> bool {
self.stack.is_empty()
}
}
#[cfg(test)]
#[path = "undo_test.rs"]
pub mod tests;
+138
View File
@@ -0,0 +1,138 @@
use crate::render::model::RenderedSelection;
use super::*;
#[test]
fn test_version_match_initial() {
let version = ContentVersion::new();
let stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
}
fn fake_reversible_actions() -> ReversibleEditorActions {
ReversibleEditorActions {
actions: vec![],
selections: ReversibleSelectionState {
next: RenderedSelectionSet::new(RenderedSelection::new(0.into(), 0.into())),
reverse: RenderedSelectionSet::new(RenderedSelection::new(0.into(), 0.into())),
},
replacement_range: ReplacementRange {
new_range: 0.into()..0.into(),
old_range: 0.into()..0.into(),
},
}
}
#[test]
fn test_version_match_after_edit() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
assert!(stack.version_match(&new_version));
assert!(!stack.version_match(&version));
}
#[test]
fn test_version_match_after_undo() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
stack.undo();
assert!(!stack.version_match(&new_version));
assert!(stack.version_match(&version));
}
#[test]
fn test_version_match_after_redo() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(5, version);
assert!(stack.version_match(&version));
let new_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), new_version);
stack.undo();
stack.redo();
assert!(stack.version_match(&new_version));
assert!(!stack.version_match(&version));
}
#[test]
fn test_version_match_after_stack_overflow() {
let version = ContentVersion::new();
let mut stack = UndoStack::new(3, version);
assert!(stack.version_match(&version));
let next_version = ContentVersion::new();
stack.push_undo_item_to_stack(fake_reversible_actions(), next_version);
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
stack.undo();
stack.undo();
stack.undo();
stack.undo();
assert!(stack.version_match(&next_version));
}
#[test]
fn test_version_match_after_nonatomic_undo_redo() {
// Make sure that after a series of nonatomic operations, the undo stack takes the newest version.
let version1 = ContentVersion::new();
let mut stack = UndoStack::new(5, version1);
assert!(stack.version_match(&version1));
let version2 = ContentVersion::new();
stack.push_non_atomic_edit(fake_reversible_actions(), NonAtomicType::Insert, version2);
let version3 = ContentVersion::new();
stack.push_non_atomic_edit(fake_reversible_actions(), NonAtomicType::Insert, version3);
assert!(stack.version_match(&version3));
stack.undo();
assert!(stack.version_match(&version1));
stack.redo();
assert!(stack.version_match(&version3));
}
#[test]
fn test_full_rollback() {
// Test that filling the capacity of the undo stack, undoing everything, and then making a new edit works.
// This was causing a panic.
let version1 = ContentVersion::new();
let mut stack = UndoStack::new(5, version1);
for _ in 0..5 {
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
}
assert_eq!(stack.capacity, stack.current_index);
for _ in 0..5 {
stack.undo();
}
assert_eq!(0, stack.current_index);
stack.push_undo_item_to_stack(fake_reversible_actions(), ContentVersion::new());
}
+143
View File
@@ -0,0 +1,143 @@
use pathfinder_color::ColorU;
use sum_tree::SumTree;
use crate::content::{
cursor::BufferSumTree,
text::{BlockLineBreakBehavior, BlockType, ColorMarker},
};
use super::text::{BufferBlockStyle, BufferSummary, BufferText, MarkerDir};
#[cfg(test)]
#[path = "validation_tests.rs"]
mod tests;
/// Validates a [`SumTree`] of content, panicking if it is not valid.
pub fn validate_content(content: &SumTree<BufferText>) {
let mut cursor = content.cursor::<(), BufferSummary>();
cursor.descend_to_first_item(content, |_| true);
let mut active_block_style: Option<BlockType> = None;
let mut active_color: Option<ColorU> = None;
while let Some(item) = cursor.item() {
let start_summary = cursor.start();
let char_offset = start_summary.text.chars;
match item {
BufferText::Text { .. } => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found character, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
BufferText::Marker { marker_type, dir } => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found style marker, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
let style_depth = start_summary.style_summary().style_counter(marker_type);
match dir {
MarkerDir::Start => {
assert!(
style_depth == 0,
"{char_offset}: Found {marker_type:?} start marker, but style was already active\nBuffer: {}",
content.debug()
);
}
MarkerDir::End => {
assert!(
style_depth == 1,
"{char_offset}: Found {marker_type:?} end marker, but style was not active\nBuffer: {}",
content.debug()
);
}
}
}
BufferText::Newline => match active_block_style.clone() {
Some(BlockType::Text(block_style)) => {
assert!(
block_style.line_break_behavior() == BlockLineBreakBehavior::NewLine,
"{char_offset}: Found newline, but active block style only supports single line\nBuffer: {}",
content.debug()
);
}
Some(BlockType::Item(_)) => {
panic!(
"{char_offset}: Found newline, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
None => (),
},
BufferText::BlockItem { item_type } => {
active_block_style = Some(BlockType::Item(item_type.clone()));
}
BufferText::BlockMarker { marker_type } => {
if Some(BlockType::Text(BufferBlockStyle::PlainText)) == active_block_style {
assert!(
marker_type != &BufferBlockStyle::PlainText,
"{char_offset}: Found plain text marker when the active style is plain text\nBuffer: {}",
content.debug()
);
}
active_block_style = Some(BlockType::Text(marker_type.clone()));
}
BufferText::Color(color_marker) => {
assert!(
matches!(
active_block_style,
Some(BlockType::Text(BufferBlockStyle::CodeBlock { .. }))
),
"{char_offset}: Found syntax color marker, but active block item is not a code block\nBuffer: {}",
content.debug()
);
match color_marker {
ColorMarker::Start(color) => {
assert!(
active_color.is_none(),
"{char_offset}: Found a starting syntax color marker when there is already an active syntax color\nBuffer: {}",
content.debug()
);
active_color = Some(*color);
}
ColorMarker::End => {
assert!(
active_color.is_some(),
"{char_offset}: Found an ending syntax color marker when there is no active syntax color\nBuffer: {}",
content.debug()
);
active_color = None;
}
}
}
BufferText::Placeholder { .. } | BufferText::Link(_) => {
assert!(
!matches!(active_block_style, Some(BlockType::Item(_))),
"{char_offset}: Found link/placeholder, but active block item does not decorate text\nBuffer: {}",
content.debug()
);
}
}
assert!(
active_block_style.is_some(),
"{char_offset}: Buffer doesn't have an active block style.\nBuffer: {}",
content.debug()
);
cursor.next();
}
// Buffers must end as plain text.
assert!(
active_block_style
.clone()
.is_some_and(|style| style == BlockType::Text(BufferBlockStyle::PlainText)),
"Buffer ends as {active_block_style:?}, not plain text.\nBuffer: {}",
content.debug()
);
}
@@ -0,0 +1,239 @@
use crate::content::{
cursor::BufferSumTree,
text::{
BlockHeaderSize, BufferBlockItem, BufferBlockStyle, BufferText, BufferTextStyle, MarkerDir,
},
};
use sum_tree::SumTree;
use warpui::elements::ListIndentLevel;
#[test]
#[should_panic(
expected = "2: Found Weight(Bold) end marker, but style was not active\nBuffer: <text>x<b_e>"
)]
fn test_validate_unmatched_style_end() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "3: Found Italic start marker, but style was already active\nBuffer: <text>x<i_s>y<i_s><i_e>"
)]
fn test_validate_unmatched_style_start() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("y");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "2: Found newline, but active block style only supports single line\nBuffer: <header1>x\\n"
)]
fn test_validate_single_line_header() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::Header {
header_size: BlockHeaderSize::Header1,
},
});
tree.append_str("x");
tree.push(BufferText::Newline);
super::validate_content(&tree);
}
#[test]
/*
* TODO: this test should panic once we prevent styling in code blocks.
#[should_panic(
expected = "3: Found Bold Start marker inside runnable command block\nBuffer: <text>a<code>x<b_s>y<text><b_e>"
)]
*/
fn test_validate_styled_code() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("a");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("x");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::Start,
});
tree.append_str("y");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::bold(),
dir: MarkerDir::End,
});
// The marker pairs are balanced, but there cannot be a bold marker inside
// a runnable command.
super::validate_content(&tree);
}
// #[test]
// #[should_panic(
// expected = "2: Tried to start a RunnableCodeBlock block, but Bold was active\nBuffer: a<b_s>b<code_s>c<code_e>d<b_e>"
// )]
// fn test_validate_start_block_with_style() {
// let mut tree = SumTree::new();
// tree.push('a'.into());
// tree.push(BufferText::Marker {
// marker_type: BufferTextStyle::Bold,
// dir: MarkerDir::Start,
// });
// tree.push('b'.into());
// tree.push(BufferText::BlockMarker {
// marker_type: BufferBlockStyle::CodeBlock,
// dir: MarkerDir::Start,
// });
// tree.push('c'.into());
// tree.push(BufferText::BlockMarker {
// marker_type: BufferBlockStyle::CodeBlock,
// dir: MarkerDir::End,
// });
// tree.push('d'.into());
// tree.push(BufferText::Marker {
// marker_type: BufferTextStyle::Bold,
// dir: MarkerDir::End,
// });
// // The marker pairs are balanced, but there cannot be a style active when
// // starting a runnable command block.
// super::validate_content(&tree);
// }
#[test]
#[should_panic(expected = "0: Buffer doesn't have an active block style.\nBuffer: \\ny")]
fn test_validate_buffer_without_start_marker() {
let mut tree = SumTree::new();
tree.append_str("\ny");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "2: Found plain text marker when the active style is plain text\nBuffer: <text>y<text>x"
)]
fn test_validate_buffer_with_dup_text_marker() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("y");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("x");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "Buffer ends as Some(Text(UnorderedList { indent_level: One })), not plain text.\nBuffer: <text>t<ul0>l"
)]
fn test_validate_buffer_ends_with_plain_text() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("t");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::UnorderedList {
indent_level: ListIndentLevel::One,
},
});
tree.append_str("l");
super::validate_content(&tree);
}
#[test]
#[should_panic(
expected = "1: Found character, but active block item does not decorate text\nBuffer: <hr>t"
)]
fn test_validate_block_item_not_decorating_text() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockItem {
item_type: BufferBlockItem::HorizontalRule,
});
tree.append_str("t");
super::validate_content(&tree);
}
#[test]
fn test_validate_ok() {
let mut tree = SumTree::new();
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
tree.append_str("a");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::Start,
});
tree.append_str("i");
tree.push(BufferText::Marker {
marker_type: BufferTextStyle::Italic,
dir: MarkerDir::End,
});
tree.push(BufferText::BlockItem {
item_type: BufferBlockItem::HorizontalRule,
});
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("x");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::CodeBlock {
code_block_type: Default::default(),
},
});
tree.append_str("z");
tree.push(BufferText::BlockMarker {
marker_type: BufferBlockStyle::PlainText,
});
// This should not panic.
super::validate_content(&tree);
}
+18
View File
@@ -0,0 +1,18 @@
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone, PartialEq, Debug, Copy, Eq, PartialOrd, Ord, Hash)]
pub struct BufferVersion(usize);
impl BufferVersion {
/// Constructs a new app-unique content version.
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
BufferVersion(raw)
}
pub fn as_usize(&self) -> usize {
self.0
}
}
+13
View File
@@ -0,0 +1,13 @@
use warpui::ModelContext;
use crate::content::{buffer::BufferSnapshot, edit::PreciseDelta, version::BufferVersion};
pub trait DecorationLayer {
fn update_internal_state_with_delta(
&mut self,
deltas: &[PreciseDelta],
content_version: BufferVersion,
content: BufferSnapshot,
ctx: &mut ModelContext<Self>,
);
}
+152
View File
@@ -0,0 +1,152 @@
//! Interoperability traits for interaction between the generic rendering/content layers and parent
//! editor layers.
use std::{any::Any, cell::Ref, ops::Range};
use num_traits::SaturatingSub;
use pathfinder_color::ColorU;
use rangemap::{RangeMap, RangeSet};
use warpui::{
Action, AppContext, Element, TypedActionView, View, elements::Border,
text_layout::PaintStyleOverride,
};
use crate::{content::version::BufferVersion, render::element::RichTextAction};
use string_offset::CharOffset;
/// Interface between a `RichTextElement` and its containing editor view.
pub trait EditorView
where
// See https://github.com/rust-lang/rust/issues/20671 and the linked blogs/issues.
// Logically, EditorView requires TypedActionView as a supertrait, but structuring the
// requirements this way means we don't have to duplicate the bounds in `RichTextElement`.
Self: Sized + View + TypedActionView<Action = Self::RichTextAction>,
{
type RichTextAction: RichTextAction<Self> + Action;
/// Get the backing model for a runnable command block. The implementation may return `None`
/// if it does not want interactive features for the block.
fn runnable_command_at<'a>(
&self,
block_offset: CharOffset,
ctx: &'a AppContext,
) -> Option<&'a dyn RunnableCommandModel>;
fn embedded_item_at<'a>(
&self,
block_offset: CharOffset,
ctx: &'a AppContext,
) -> Option<&'a dyn EmbeddedItemModel>;
/// Returns text decorations (e.g., syntax highlighting, underlines) for the given viewport ranges.
///
/// The lifetime `'a` must be tied to both `&'a self` and `&'a AppContext` because:
/// - `TextDecoration` contains `Ref<'a, RangeMap>` which borrows from cached data
/// - The cache may be stored in models accessed through `AppContext` (e.g., syntax tree)
/// - Both `self` and `ctx` must live at least as long as the returned `TextDecoration`
fn text_decorations<'a>(
&'a self,
_viewport_ranges: RangeSet<CharOffset>,
_content_version: Option<BufferVersion>,
_ctx: &'a AppContext,
) -> TextDecoration<'a> {
TextDecoration::default()
}
}
#[derive(Default)]
pub struct TextDecoration<'a> {
/// Base color map borrowed from cache (e.g., syntax highlighting)
pub base_color_map: Option<Ref<'a, RangeMap<CharOffset, ColorU>>>,
/// Override color map for colors that take precedence over base (e.g., search highlights)
pub override_color_map: Option<RangeMap<CharOffset, ColorU>>,
pub underline_range: Option<RangeMap<CharOffset, ColorU>>,
}
impl<'a> TextDecoration<'a> {
/// Get the color decoration for the given charoffset range. The returned range
/// should be offset-ed from the start of the input range.
pub fn to_paint_style_override(&self, range: Range<CharOffset>) -> PaintStyleOverride {
// Merge base and override color maps, with override taking priority
let mut color = RangeMap::new();
// First apply base colors
if let Some(base_map) = &self.base_color_map {
color.extend(Self::extract_overlapping_ranges(&range, base_map));
}
// Then apply override colors (will overwrite base colors in overlapping ranges)
if let Some(override_map) = &self.override_color_map {
color.extend(Self::extract_overlapping_ranges(&range, override_map));
}
let underline = Self::find_overlapping_ranges(&range, &self.underline_range);
PaintStyleOverride::default()
.with_color(color)
.with_underline(underline)
}
fn extract_overlapping_ranges(
incoming_range: &Range<CharOffset>,
style_map: &RangeMap<CharOffset, ColorU>,
) -> RangeMap<usize, ColorU> {
style_map
.overlapping(incoming_range)
.map(|(range, color)| {
let start = range.start.saturating_sub(&incoming_range.start).as_usize();
let end = range
.end
.min(incoming_range.end)
.saturating_sub(&incoming_range.start)
.as_usize();
(start..end, *color)
})
.collect()
}
fn find_overlapping_ranges(
incoming_range: &Range<CharOffset>,
style_map: &Option<RangeMap<CharOffset, ColorU>>,
) -> RangeMap<usize, ColorU> {
match style_map {
Some(style_map) => Self::extract_overlapping_ranges(incoming_range, style_map),
None => RangeMap::default(),
}
}
}
pub trait EmbeddedItemModel {
fn render_item_footer(&self, ctx: &AppContext) -> Option<Box<dyn Element>>;
fn border(&self, app: &AppContext) -> Option<Border>;
fn render_remove_embedding_button(&self, ctx: &AppContext) -> Option<Box<dyn Element>>;
}
/// API for the logical editor model backing a runnable command block.
pub trait RunnableCommandModel {
/// Builds the footer component for the command or code block
/// This can include a button to insert a command into the terminal input.
fn render_block_footer(&self, editor_is_focused: bool, ctx: &AppContext) -> Box<dyn Element>;
/// Border for this command block. Models can use this for state-dependent accents like
/// success/failure/selection colors.
///
/// If `None`, the default code border in the model's `RichTextStyles` is used instead.
fn border(&self, app: &AppContext) -> Option<Border>;
fn as_any(&self) -> &dyn Any;
}
/// A navigation key, which could be propagated from an editor to its parent view.
#[derive(Debug, Clone, Copy)]
pub enum NavigationKey {
Tab,
ShiftTab,
Up,
Down,
PageUp,
PageDown,
Left,
Right,
}
+9
View File
@@ -0,0 +1,9 @@
pub mod content;
pub mod decoration;
pub mod editor;
pub mod model;
pub mod multiline;
mod parallel_util;
pub mod render;
pub mod search;
pub mod selection;
File diff suppressed because it is too large Load Diff
+544
View File
@@ -0,0 +1,544 @@
//! Common types for dealing with multi-line strings safely.
//!
//! There are two pairs of string types, similar to `String` / `str` and `PathBuf` / `Path`:
//! * [`MultilineString`] and [`MultilineStr`] track the line ending at compile time. These are
//! zero-cost wrappers over [`String`] and [`str`].
//! * [`AnyMultilineString`] and [`AnyMultilineStr`] track the line ending at runtime. This is more
//! flexible, but incurs a space and ergonomic cost.
//!
//! Generally, prefer [`MultilineString`] and [`MultilineStr`] when working consistently with a set
//! line format (for example, the editor buffer only uses `\n` for line endings). When reading
//! external text, use [`AnyMultilineString`] and [`AnyMultilineStr`] unless normalizing to a set
//! ending.
//!
//! ## Invariants
//! All multiline string types guarantee that their backing string uses the specified line ending.
//! They do not support mixed line endings. All constructors will either normalize to a single line
//! ending or return an error.
//!
//! ## Creating Multiline Strings
//!
//! If you are managing line endings at runtime, use [`AnyMultilineString::infer`]. This will infer
//! a line ending from the input text, normalizing it if necessary.
//!
//! To create a string with a guaranteed, statically-known ending, use either [`MultilineString::apply`]
//! or [`MultilineStr::apply`]. The latter avoids allocating if the input string's line ending is already
//! correct, but may borrow its input.
//!
//! If you already know the ending of a string (e.g. it's a literal), use [`MultilineStr::try_new`].
//!
//! ## Converting Line Endings
//!
//! If you already have a multiline string type, and need to convert to a different line ending, use
//! one of the following methods:
//!
//! * `to_format` for converting to a statically-known ending ([`MultilineStr::to_format`],
//! [`AnyMultilineString::to_format`])
//! * [`AnyMultilineString::to_line_ending`] or [`AnyMultilineString::into_line_ending`] to convert
//! to a line ending known at runtime
use std::{
borrow::{Borrow, Cow},
fmt,
marker::PhantomData,
ops::Deref,
};
use itertools::Itertools as _;
use line_ending::LineEnding;
use warp_core::platform::SessionPlatform;
/// A line ending format. This is the compile-time equivalent to [`LineEnding`].
pub trait LineFormat {
/// The `LineEnding` corresponding to this format.
fn ending() -> LineEnding;
/// Apply this line ending format to `s`, replacing any other line endings.
fn apply_to(s: &str) -> String {
// TODO: LineEnding::apply is pretty inefficient, it may be worth optimizing.
Self::ending().apply(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LF;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CRLF;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CR;
/// An owned multiline string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultilineString<F: LineFormat> {
_format: PhantomData<F>,
inner: String,
}
/// A borrowed multiline string.
#[repr(transparent)]
#[derive(Debug, PartialEq, Eq)]
pub struct MultilineStr<F: LineFormat> {
_format: PhantomData<F>,
inner: str,
}
/// A string of multiline text, with an associated line ending.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnyMultilineString {
inner: String,
line_ending: LineEnding,
}
/// A borrowed multiline string, with an associated line ending.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnyMultilineStr<'a> {
inner: &'a str,
line_ending: LineEnding,
}
/// The error returned when a string did not have the expected line ending.
#[derive(Debug, thiserror::Error)]
#[error("Expected line ending {expected:?}, but was {actual:?}")]
pub struct IncorrectLineEndingError {
pub expected: LineEnding,
pub actual: LineEnding,
}
impl LineFormat for LF {
fn ending() -> LineEnding {
LineEnding::LF
}
fn apply_to(s: &str) -> String {
// As opposed to `apply`, `normalize` avoids an extra conversion pass.
LineEnding::normalize(s)
}
}
impl LineFormat for CRLF {
fn ending() -> LineEnding {
LineEnding::CRLF
}
}
impl LineFormat for CR {
fn ending() -> LineEnding {
LineEnding::CR
}
fn apply_to(s: &str) -> String {
// This avoids a normalization pass - we can use the same implementation as `normalize`,
// but with a different replacement string.
s.replace("\r\n", "\r").replace('\n', "\r")
}
}
impl<F: LineFormat> MultilineString<F> {
/// Create a new `MultilineString` that assumes the given line ending. The caller must
/// guarantee that `s` already uses the given line ending - prefer [`Self::apply`] instead.
#[inline]
fn new_unchecked(s: impl Into<String>) -> Self {
Self {
_format: PhantomData,
inner: s.into(),
}
}
/// Create a new `MultilineString` by unconditionally applying `F` to `s`.
pub fn apply(s: impl AsRef<str>) -> Self {
Self::new_unchecked(F::apply_to(s.as_ref()))
}
/// Extract the underlying string, which is guaranteed to use `F` as its line ending.
pub fn into_string(self) -> String {
self.inner
}
}
impl<F: LineFormat> fmt::Display for MultilineString<F> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.inner.fmt(f)
}
}
impl<F: LineFormat> fmt::Display for MultilineStr<F> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.inner.fmt(f)
}
}
impl<F: LineFormat> MultilineStr<F> {
/// Create a new `MultilineStr` that assumes the given line ending. The caller must
/// guarantee that `s` already uses the given line ending - prefer [`MultilineString::apply`]
/// if the format is not known.
#[inline]
fn new_unchecked<S: AsRef<str> + ?Sized>(s: &S) -> &Self {
// Safety: Because `MultilineStr` is `#repr(transparent)`, it has an identical in-memory
// representation to `str`. Therefore, we can cast from `*str` to `*MultilineStr<F>`, and
// then to `&MultilineStr<F>`, with the same lifetime as the original `&str`.
// See https://users.rust-lang.org/t/creating-types-analoguous-to-path-and-pathbuf/117310.
unsafe { &*(s.as_ref() as *const str as *const Self) }
}
/// Create a new `MultilineStr` from a string slice. This returns an error if the slice does
/// not already use `F` for line endings, or if it contains mixed line endings.
pub fn try_new<S: AsRef<str> + ?Sized>(s: &S) -> Result<&Self, IncorrectLineEndingError> {
let s = s.as_ref();
let endings = evaluate_line_endings(s);
match endings {
// If there are no line endings, then they trivially match.
TextLineEndings::SingleLine => Ok(MultilineStr::new_unchecked(s)),
TextLineEndings::MultiLine {
primary_ending,
mixed_endings,
} => {
if primary_ending == F::ending() && !mixed_endings {
Ok(MultilineStr::new_unchecked(s))
} else {
Err(IncorrectLineEndingError {
expected: F::ending(),
actual: primary_ending,
})
}
}
}
}
/// Create a `MultilineStr` or [`MultilineString`] from `s`, guaranteed to use `F` for line endings.
///
/// If `s` already uses `F` for line endings, then no new allocation is needed and a [`Cow::Borrowed`] is returned.
/// Otherwise, a new [`MultilineString`] is allocated with the converted text.
pub fn apply<S: AsRef<str> + ?Sized>(s: &S) -> Cow<'_, Self> {
let s = s.as_ref();
let endings = evaluate_line_endings(s);
match endings {
TextLineEndings::SingleLine => Cow::Borrowed(MultilineStr::new_unchecked(s)),
TextLineEndings::MultiLine {
primary_ending,
mixed_endings,
} => {
if primary_ending == F::ending() && !mixed_endings {
Cow::Borrowed(MultilineStr::new_unchecked(s))
} else {
Cow::Owned(MultilineString::<F>::apply(s))
}
}
}
}
/// Convert to an owned [`MultilineString`] with a new line ending, `G`. This must allocate in
/// order to create a string with the new line ending.
pub fn to_format<G: LineFormat>(&self) -> MultilineString<G> {
let converted = G::apply_to(&self.inner);
MultilineString::new_unchecked(converted)
}
/// Get the underlying string, which is guaranteed to use `F` as its line ending.
pub fn as_str(&self) -> &str {
&self.inner
}
/// Iterate over the lines of this string, split by `F`.
pub fn lines(&self) -> impl Iterator<Item = &str> {
self.inner.split(F::ending().as_str())
}
}
impl<F: LineFormat> ToOwned for MultilineStr<F> {
type Owned = MultilineString<F>;
fn to_owned(&self) -> Self::Owned {
MultilineString {
_format: PhantomData,
inner: self.inner.to_owned(),
}
}
}
impl<F: LineFormat> Deref for MultilineString<F> {
type Target = MultilineStr<F>;
#[inline]
fn deref(&self) -> &Self::Target {
MultilineStr::new_unchecked(self.inner.as_str())
}
}
impl<F: LineFormat> Borrow<MultilineStr<F>> for MultilineString<F> {
#[inline]
fn borrow(&self) -> &MultilineStr<F> {
self
}
}
impl<F: LineFormat> AsRef<MultilineStr<F>> for MultilineString<F> {
#[inline]
fn as_ref(&self) -> &MultilineStr<F> {
self
}
}
impl<F: LineFormat> TryFrom<AnyMultilineString> for MultilineString<F> {
type Error = IncorrectLineEndingError;
/// Attempt to convert an [`AnyMultilineString`] to a [`MultilineString`]. This fails if the
/// line ending is not `F`. Use [`AnyMultilineString::to_format`] to infallibly convert line
/// endings.
fn try_from(value: AnyMultilineString) -> Result<Self, Self::Error> {
if value.line_ending == F::ending() {
Ok(MultilineString::new_unchecked(value.inner))
} else {
Err(IncorrectLineEndingError {
expected: F::ending(),
actual: value.line_ending,
})
}
}
}
impl<F: LineFormat> TryFrom<String> for MultilineString<F> {
type Error = IncorrectLineEndingError;
/// Attempt to convert a [`String`] to a [`MultilineString`]. This fails if the inferred line
/// ending is not `F`.
fn try_from(value: String) -> Result<Self, Self::Error> {
AnyMultilineString::infer(value).try_into()
}
}
impl<'a, F: LineFormat> TryFrom<AnyMultilineStr<'a>> for &'a MultilineStr<F> {
type Error = IncorrectLineEndingError;
/// Attempt to convert an [`AnyMultilineStr`] to a [`MultilineStr`]. This fails if the
/// line ending is not `F`. Use [`AnyMultilineString::to_format`] to infallibly convert line
/// endings.
fn try_from(value: AnyMultilineStr<'a>) -> Result<Self, Self::Error> {
if value.line_ending == F::ending() {
Ok(MultilineStr::new_unchecked(value.inner))
} else {
Err(IncorrectLineEndingError {
expected: F::ending(),
actual: value.line_ending,
})
}
}
}
impl AnyMultilineString {
/// Create a new `AnyMultilineString` with the given line ending. The caller must guarantee that `line_ending`
/// is the line ending used in `text`.
pub fn new_unchecked(text: impl Into<String>, line_ending: LineEnding) -> Self {
Self {
inner: text.into(),
line_ending,
}
}
/// Create a new `AnyMultilineString` with the given text normalized via [`LineEnding::normalize`].
///
/// This converts all line endings to `\n`.
pub fn normalize_to_linefeed(text: impl AsRef<str>) -> Self {
MultilineString::<LF>::apply(text).into()
}
/// Create a new `AnyMultilineString` from individual lines and the desired line ending.
pub fn from_lines<I: IntoIterator<Item = String>>(lines: I, line_ending: LineEnding) -> Self {
let text = lines.into_iter().join(line_ending.as_str());
Self::new_unchecked(text, line_ending)
}
/// Create a new `AnyMultilineString` with the line ending inferred from the input text.
pub fn infer(text: impl Into<String>) -> Self {
let mut text = text.into();
let endings = evaluate_line_endings(&text);
// TODO: Figure out how to get a SessionPlatform here.
let primary_ending = endings.primary_ending(None);
if endings.is_mixed() {
// If the text contains mixed line endings (uncommon), they must be normalized to a
// single ending to uphold the `AnyMultilineString` contract.
text = primary_ending.apply(&text);
}
Self::new_unchecked(text, primary_ending)
}
/// The underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending.
pub fn as_str(&self) -> &str {
&self.inner
}
/// Extract the underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending.
pub fn into_string(self) -> String {
self.inner
}
/// The configured line ending of this string.
pub fn line_ending(&self) -> LineEnding {
self.line_ending
}
/// Converts this text to use `ending`. This is similar to [`to_line_ending`], but consumes
/// `self` for convenience if the original value is no longer needed. It will reuse the
/// existing allocation if possible.
pub fn into_line_ending(self, ending: LineEnding) -> Self {
if self.line_ending == ending {
self
} else {
let new_text = ending.apply(&self.inner);
Self::new_unchecked(new_text, ending)
}
}
/// Converts this text to use `ending`.
///
/// This method returns a [`Cow<'_, Self>`]. If our line ending is already `ending`, it
/// returns a reference to `self`. Otherwise, it allocates a new `AnyMultilineString` with the
/// new line ending applied.
pub fn to_line_ending(&self, ending: LineEnding) -> Cow<'_, Self> {
if self.line_ending == ending {
Cow::Borrowed(self)
} else {
let new_text = ending.apply(&self.inner);
Cow::Owned(Self::new_unchecked(new_text, ending))
}
}
/// Converts this text to use `F` as its line ending.
///
/// This method returns a [`Cow<'_, MultilineStr<F>>`]. If our line ending is already `F`, it
/// returns a reference to the string backing `self`. Otherwise, it allocates a new
/// [`MultilineString`] with the new line ending applied.
pub fn to_format<F: LineFormat>(&self) -> Cow<'_, MultilineStr<F>> {
if self.line_ending == F::ending() {
Cow::Borrowed(MultilineStr::new_unchecked(self.inner.as_str()))
} else {
Cow::Owned(MultilineString::<F>::apply(self.inner.as_str()))
}
}
/// Iterate over the lines of this string, split by [`Self::line_ending`].
pub fn lines(&self) -> impl Iterator<Item = &str> {
self.inner.split(self.line_ending.as_str())
}
}
impl<F: LineFormat> From<MultilineString<F>> for AnyMultilineString {
fn from(value: MultilineString<F>) -> Self {
Self {
inner: value.into_string(),
line_ending: F::ending(),
}
}
}
impl<'a> AnyMultilineStr<'a> {
pub fn new_unchecked<S: AsRef<str> + ?Sized>(text: &'a S, line_ending: LineEnding) -> Self {
Self {
inner: text.as_ref(),
line_ending,
}
}
/// Get the underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending.
pub fn as_str(&self) -> &str {
self.inner
}
/// The configured line ending of this string.
pub fn line_ending(&self) -> LineEnding {
self.line_ending
}
}
impl<'a, F: LineFormat> From<&'a MultilineStr<F>> for AnyMultilineStr<'a> {
fn from(value: &'a MultilineStr<F>) -> Self {
Self {
inner: value.as_str(),
line_ending: F::ending(),
}
}
}
/// Returns the line ending style to use for the input string.
///
/// This is the most common line ending in the string. If there are no line endings, the platform
/// default is used.
pub fn infer_line_ending(text: &str, platform: Option<&SessionPlatform>) -> LineEnding {
evaluate_line_endings(text).primary_ending(platform)
}
/// Results of analyzing the line endings in a string.
enum TextLineEndings {
/// The text has no line endings.
SingleLine,
/// The text contains at least one line ending.
MultiLine {
/// The most common line ending in the string.
primary_ending: LineEnding,
/// Whether the string contains a mix of line endings.
mixed_endings: bool,
},
}
impl TextLineEndings {
/// Whether or not the string contained a mix of line endings.
fn is_mixed(&self) -> bool {
match self {
TextLineEndings::SingleLine => false,
TextLineEndings::MultiLine { mixed_endings, .. } => *mixed_endings,
}
}
/// The primary line ending of the string. For single-line strings, this is the platform default.
#[allow(clippy::disallowed_methods)]
fn primary_ending(&self, platform: Option<&SessionPlatform>) -> LineEnding {
match self {
TextLineEndings::SingleLine => platform
.map_or_else(LineEnding::from_current_platform, |platform| {
platform.default_line_ending()
}),
TextLineEndings::MultiLine { primary_ending, .. } => *primary_ending,
}
}
}
/// Evaluates the line endings present in a string.
///
/// The primary line ending is the line ending type that occurred most often in a string.
fn evaluate_line_endings(text: &str) -> TextLineEndings {
// This is similar to LineEnding::from, but with platform-aware tie-breaking.
let scores = LineEnding::score_mixed_types(text);
// The score_mixed_types implementation guarantees a score for each line ending.
let crlf_score = scores[&LineEnding::CRLF];
let lf_score = scores[&LineEnding::LF];
let cr_score = scores[&LineEnding::CR];
// Use the most-prevalent line ending, or the platform ending if there are no lines.
// // In case of a tie, prefer LF over CRLF as Unix-style endings are overall more common.
let max_score = crlf_score.max(lf_score).max(cr_score);
if max_score == 0 {
TextLineEndings::SingleLine
} else if max_score == lf_score {
TextLineEndings::MultiLine {
primary_ending: LineEnding::LF,
mixed_endings: crlf_score > 0 || cr_score > 0,
}
} else if max_score == crlf_score {
TextLineEndings::MultiLine {
primary_ending: LineEnding::CRLF,
mixed_endings: lf_score > 0 || cr_score > 0,
}
} else {
TextLineEndings::MultiLine {
primary_ending: LineEnding::CR,
mixed_endings: crlf_score > 0 || lf_score > 0,
}
}
}
#[cfg(test)]
#[path = "multiline_tests.rs"]
mod tests;
+175
View File
@@ -0,0 +1,175 @@
// Allow the use of `LineEnding::from_current_platform` in this test file, since
// it's used in the implementation of `infer_line_ending`.
#![allow(clippy::disallowed_methods)]
use super::*;
use warp_core::platform::SessionPlatform;
#[test]
fn test_infer_line_ending_empty_file() {
assert_eq!(
infer_line_ending("", None),
LineEnding::from_current_platform()
);
}
#[test]
fn test_infer_line_ending_single_line() {
assert_eq!(
infer_line_ending("I have no line endings", None),
LineEnding::from_current_platform()
);
}
#[test]
fn test_infer_line_ending_unix_subsystem_for_windows() {
assert_eq!(
infer_line_ending("", Some(&SessionPlatform::WSL)),
LineEnding::LF
);
assert_eq!(
infer_line_ending("", Some(&SessionPlatform::MSYS2)),
LineEnding::LF
);
}
#[test]
fn test_infer_line_ending_native_platform() {
assert_eq!(
infer_line_ending("", Some(&SessionPlatform::Native)),
LineEnding::from_current_platform()
);
}
#[test]
fn test_infer_line_ending_windows() {
assert_eq!(
infer_line_ending("This\r\nhas\r\nlines\r\n", None),
LineEnding::CRLF
);
}
#[test]
fn test_infer_line_ending_nix() {
assert_eq!(infer_line_ending("This\nhas\nlines", None), LineEnding::LF);
}
#[test]
fn test_infer_line_ending_mixed() {
// There's no correct line ending in this case - we follow the `line-ending` crate's
// most-common-ending approach.
assert_eq!(
infer_line_ending("This\r\nhas\r\nmixed\nline endings", None),
LineEnding::CRLF
);
assert_eq!(
infer_line_ending("This\nhas\r\nmixed\nline endings", None),
LineEnding::LF
);
assert_eq!(
infer_line_ending("This\r\nhas\nmixed\r\nline\nendings", None),
LineEnding::LF
);
}
#[test]
fn test_apply_lf() {
let original = "This\r\nhas\nevery\rline ending";
assert_eq!(LF::apply_to(original), "This\nhas\nevery\nline ending");
}
#[test]
fn test_apply_cr() {
let original = "This\r\nhas\nevery\rline ending";
assert_eq!(CR::apply_to(original), "This\rhas\revery\rline ending");
}
#[test]
fn test_apply_crlf() {
let original = "This\r\nhas\nevery\rline ending";
assert_eq!(
CRLF::apply_to(original),
"This\r\nhas\r\nevery\r\nline ending"
);
}
#[test]
fn test_multiline_str_try_new_same_ending() {
let s = MultilineStr::<LF>::try_new("One\nTwo").expect("Should convert successfully");
assert_eq!(s.as_str(), "One\nTwo");
}
#[test]
fn test_multiline_str_try_new_different_ending() {
let s = MultilineStr::<LF>::try_new("One\r\nTwo");
assert!(s.is_err());
}
#[test]
fn test_multiline_str_try_new_mixed_ending() {
let s = MultilineStr::<LF>::try_new("One\r\nTwo\nThree\nFour");
assert!(s.is_err());
}
#[test]
fn test_multiline_str_new_single_line() {
let s = MultilineStr::<LF>::try_new("One").expect("Should convert successfully");
assert_eq!(s.as_str(), "One");
}
#[test]
fn test_multiline_str_apply_same_ending() {
let converted = MultilineStr::<LF>::apply("One\nTwo");
assert_eq!(
converted,
Cow::Borrowed(MultilineStr::new_unchecked("One\nTwo"))
);
}
#[test]
fn test_multiline_str_apply_different_ending() {
let converted = MultilineStr::<LF>::apply("One\r\nTwo\r\nThree");
assert_eq!(
converted,
Cow::Owned(MultilineString::new_unchecked("One\nTwo\nThree"))
)
}
#[test]
fn test_multiline_str_apply_single_line() {
let converted = MultilineStr::<LF>::apply("One");
assert_eq!(converted, Cow::Borrowed(MultilineStr::new_unchecked("One")));
}
#[test]
fn test_multiline_str_apply_mixed() {
// The most common ending is already LF, *but* there are mixed endings so normalization is needed.
let converted = MultilineStr::<LF>::apply("One\r\nTwo\nThree\nFour");
assert_eq!(
converted,
Cow::Owned(MultilineString::new_unchecked("One\nTwo\nThree\nFour"))
);
}
#[test]
fn test_any_infer_single_ending() {
let inferred = AnyMultilineString::infer("One\nTwo\nThree");
assert_eq!(inferred.as_str(), "One\nTwo\nThree");
assert_eq!(inferred.line_ending(), LineEnding::LF);
}
#[test]
fn test_any_infer_single_line() {
let inferred = AnyMultilineString::infer("One");
assert_eq!(inferred.as_str(), "One");
assert_eq!(inferred.line_ending(), LineEnding::from_current_platform());
}
#[test]
fn test_any_infer_mixed() {
let inferred = AnyMultilineString::infer("One\r\nTwo\nThree\r\nFour");
// The text should be normalized to CRLF.
assert_eq!(inferred.as_str(), "One\r\nTwo\r\nThree\r\nFour");
assert_eq!(inferred.line_ending(), LineEnding::CRLF);
}
+46
View File
@@ -0,0 +1,46 @@
//! Parallelism utilities.
use rayon::iter::{FromParallelIterator, IntoParallelIterator, ParallelExtend, ParallelIterator};
/// `Last` is a helper to extract the last value of a [`ParallelIterator`].
///
/// It can be used with [`ParallelIterator::collect`], [`ParallelIterator::unzip`], and similar
/// methods.
pub struct Last<T> {
result: Option<T>,
}
impl<T> Last<T> {
/// Extract the collected value.
pub fn into_inner(self) -> Option<T> {
self.result
}
}
impl<T> Default for Last<T> {
fn default() -> Self {
Self { result: None }
}
}
impl<T: Send> FromParallelIterator<T> for Last<T> {
fn from_par_iter<I>(par_iter: I) -> Self
where
I: IntoParallelIterator<Item = T>,
{
let mut last = Self::default();
last.par_extend(par_iter);
last
}
}
impl<T: Send> ParallelExtend<T> for Last<T> {
fn par_extend<I>(&mut self, par_iter: I)
where
I: IntoParallelIterator<Item = T>,
{
// The find_last implementation does a bunch of bookkeeping to short-circuit once it finds
// the most-last match, so rely on that here.
self.result = par_iter.into_par_iter().find_last(|_| true)
}
}
@@ -0,0 +1,176 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Icon,
ParentElement, Radius, Shrinkable, Text,
},
geometry::vector::vec2f,
};
use crate::{
editor::EmbeddedItemModel,
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RichTextStyles, viewport::ViewportItem},
},
};
use super::RenderableBlock;
pub struct RenderableBrokenEmbedding {
row: Box<dyn Element>,
viewport_item: ViewportItem,
}
impl RenderableBrokenEmbedding {
pub fn new(
viewport_item: ViewportItem,
styles: &RichTextStyles,
model: Option<&dyn EmbeddedItemModel>,
ctx: &AppContext,
) -> Self {
let icon = ConstrainedBox::new(
Icon::new(
styles.broken_link_style.icon_path,
styles.broken_link_style.icon_color,
)
.with_opacity(1.0)
.finish(),
)
.with_height(styles.base_text.font_size + 2.)
.with_width(styles.base_text.font_size + 2.)
.finish();
let text = Container::new(
Text::new_inline(
"Embed not found",
styles.base_text.font_family,
styles.base_text.font_size,
)
.with_color(styles.placeholder_color)
.finish(),
)
.with_padding_left(8.)
.finish();
let mut row = Flex::row()
.with_child(icon)
.with_child(text)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(element) = model.and_then(|model| model.render_remove_embedding_button(ctx)) {
row.add_child(Shrinkable::new(1., Empty::new().finish()).finish());
row.add_child(Align::new(element).right().finish());
}
Self {
viewport_item,
row: row.finish(),
}
}
}
impl RenderableBlock for RenderableBrokenEmbedding {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &crate::render::model::RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.row.layout(
SizeConstraint::strict(vec2f(
self.viewport_item.content_size.x(),
// Depending on font size, the line height could be bigger
// or smaller than font size + 2. (icon size). Choose the larger
// of the two to avoid failing to layout the element.
model
.styles()
.base_text
.line_height()
.as_f32()
.max(model.styles().base_text.font_size + 2.),
)),
ctx,
app,
);
}
fn paint(
&mut self,
model: &crate::render::model::RenderState,
ctx: &mut super::RenderContext,
app: &warpui::AppContext,
) {
let content = model.content();
let broken_link = extract_block!(self.viewport_item, content, (block, BlockItem::Embedded(item)) => block.embedded(item));
// Render as selected if the broken link is within any selection.
let selected = model.offset_in_active_selection(broken_link.start_char_offset);
// Draw the cursor if the broken link is at any cursor.
let draw_cursor = model.is_selection_head(broken_link.start_char_offset);
let styles = model.styles();
let background_rect = self.viewport_item.visible_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(background_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background(model.styles().embedding_background);
let original_content_origin = broken_link.content_origin();
let vertical_center_delta =
styles.base_text.line_height().as_f32() * (styles.base_text.baseline_ratio - 0.5);
let content_origin = original_content_origin - vec2f(0., vertical_center_delta);
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(background_rect)
.with_background(styles.selection_fill);
}
if draw_cursor {
let line_height = styles.base_text.line_height().as_f32();
// The lower right corner of the background rect is at reserved_origin + background_rect.size()
// Add some horizontal padding and minus line height vertically so it's visible and aligned to
// the bottom of the background rect.
let end_of_line_position =
broken_link.reserved_origin() + background_rect.size() + vec2f(5., -line_height);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(styles.cursor_width, line_height),
CursorData::default(),
styles,
);
}
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
self.row
.paint(ctx.content_to_screen(content_origin), ctx.paint, app);
ctx.paint.scene.stop_layer();
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
self.row.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.row.dispatch_event(event, ctx, app)
}
}
+78
View File
@@ -0,0 +1,78 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::{
element::paint::CursorData,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{
RenderContext, RenderableBlock,
paragraph::paragraph_placeholder_text,
placeholder::{self, BlockPlaceholder},
};
/// Renderable representation of invisible rich-text items. This is used for the trailing newline
/// marker.
pub struct Empty {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl Empty {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(false),
}
}
}
impl RenderableBlock for Empty {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
text: paragraph_placeholder_text(model.selections().len() == 1),
block_style: BufferBlockStyle::PlainText,
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let cursor = extract_block!(self.viewport_item, content, (block, BlockItem::TrailingNewLine(cursor)) => block.trailing_newline(cursor));
if self.placeholder.paint(cursor.content_origin(), model, ctx) {
return;
}
let selections = model.selections();
if selections
.iter()
.any(|selection| selection.is_cursor() && selection.head >= cursor.start_char_offset)
{
let base = &model.styles().base_text;
let cursor_data = CursorData {
block_width: None,
font_size: Some(base.font_size),
};
ctx.draw_and_save_cursor(
ctx.cursor_type,
cursor.content_origin(),
cursor.item.size(),
cursor_data,
model.styles(),
);
}
}
}
@@ -0,0 +1,75 @@
use crate::{
content::text::{BlockHeaderSize, BufferBlockStyle},
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{
RenderContext, RenderableBlock,
placeholder::{BlockPlaceholder, Options},
};
pub struct RenderableHeader {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl RenderableHeader {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableHeader {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
let header_size = match block {
BlockItem::Header { header_size, .. } => *header_size,
other => {
if cfg!(debug_assertions) {
panic!("Expected a header, got {other:?}");
}
BlockHeaderSize::Header6
}
};
Options {
text: header_size.label(),
block_style: BufferBlockStyle::Header { header_size },
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let (paragraph, header_size) = extract_block!(
self.viewport_item, content,
(block, BlockItem::Header { header_size, paragraph }) => (block.header(paragraph), header_size)
);
if self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
return;
}
let header_style = &model.styles().paragraph_styles(&BufferBlockStyle::Header {
header_size: *header_size,
});
ctx.draw_paragraph(&paragraph, header_style, model);
}
}
@@ -0,0 +1,84 @@
use crate::extract_block;
use crate::render::model::BlockItem;
use super::super::model::{RenderState, viewport::ViewportItem};
use super::{RenderContext, RenderableBlock};
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{CrossAxisAlignment, Empty, Flex, ParentElement};
use warpui::{
AfterLayoutContext, AppContext, Element, LayoutContext, SingletonEntity, SizeConstraint,
elements::Container, geometry::vector::vec2f,
};
/// A renderable block for hidden sections that renders a single- or double-line-height rectangle.
/// This is used for BlockItem::Hidden items that need to be visually indicated.
pub struct RenderableHiddenSection {
element: Box<dyn Element>,
viewport_item: ViewportItem,
}
impl RenderableHiddenSection {
pub fn new(viewport_item: ViewportItem, app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let row = Flex::row()
.with_child(Empty::new().finish())
.with_cross_axis_alignment(CrossAxisAlignment::Center);
let element = Container::new(row.finish())
.with_background(internal_colors::fg_overlay_1(theme))
.finish();
Self {
viewport_item,
element,
}
}
}
impl RenderableBlock for RenderableHiddenSection {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, model: &RenderState, ctx: &mut LayoutContext, app: &AppContext) {
let content = model.content();
let hidden_section = extract_block!(self.viewport_item, content, (_block, BlockItem::Hidden(config)) => config);
self.element.layout(
SizeConstraint::strict(vec2f(
model.viewport().width().as_f32(),
hidden_section.height().as_f32(),
)),
ctx,
app,
);
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
// Paint the single- or double-line-height rectangle element
let content_origin = self.viewport_item.content_bounds(ctx).origin()
+ vec2f(model.viewport().scroll_left().as_f32(), 0.);
self.element.paint(content_origin, ctx.paint, app);
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.element.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.element.dispatch_event(event, ctx, app)
}
fn is_hidden_section(&self) -> bool {
true
}
}
@@ -0,0 +1,102 @@
use warpui::{
elements::{CornerRadius, Radius},
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
};
use crate::{
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RenderState, RichTextStyles, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
/// Renderable representation of a single horizontal rule separator.
pub struct HorizontalRule {
viewport_item: ViewportItem,
}
impl HorizontalRule {
pub fn new(viewport_item: ViewportItem) -> Self {
Self { viewport_item }
}
pub fn draw_rect(
content_position: Vector2F,
selected: bool,
draw_cursor: bool,
size: Vector2F,
styles: &RichTextStyles,
ctx: &mut RenderContext,
) {
let rect_origin = ctx.content_to_screen(content_position);
let y_axis_offset = (size.y() - styles.horizontal_rule_style.rule_height).max(0.) / 2.;
let rule_bounds = RectF::new(
vec2f(rect_origin.x(), rect_origin.y() + y_axis_offset),
vec2f(size.x(), styles.horizontal_rule_style.rule_height),
);
let line_bounds = RectF::new(rect_origin, size);
ctx.paint
.scene
.draw_rect_with_hit_recording(rule_bounds)
.with_background(styles.horizontal_rule_style.color)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(line_bounds)
.with_background(styles.selection_fill);
}
if draw_cursor {
let end_of_line_position = content_position + vec2f(size.x(), 0.);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(styles.cursor_width, size.y()),
CursorData::default(),
styles,
);
}
}
}
impl RenderableBlock for HorizontalRule {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let horizontal_rule = extract_block!(self.viewport_item, content, (block, BlockItem::HorizontalRule(rule)) => block.horizontal_rule(rule));
let selected = model.offset_in_active_selection(horizontal_rule.start_char_offset);
let draw_cursor = model.is_selection_head(horizontal_rule.start_char_offset);
Self::draw_rect(
horizontal_rule.content_origin(),
selected,
draw_cursor,
horizontal_rule.item.line_size()
- vec2f(self.viewport_item.spacing.x_axis_offset().as_f32(), 0.),
model.styles(),
ctx,
);
}
}
+104
View File
@@ -0,0 +1,104 @@
use warpui::{
Element, SizeConstraint,
elements::{CacheOption, Image},
geometry::vector::vec2f,
};
use crate::{
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
pub struct RenderableImage {
viewport_item: ViewportItem,
// TODO: The AssetCache does not currently support automatic eviction of assets when they are
// dropped. We should consider implementing a mechanism to unload images when they are no longer
// visible or referenced.
image_element: Option<Box<dyn Element>>,
}
impl RenderableImage {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
image_element: None,
}
}
}
impl RenderableBlock for RenderableImage {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
let content = model.content();
let (asset_source, config) = extract_block!(
self.viewport_item,
content,
(_block, BlockItem::Image { asset_source, config, .. }) => (asset_source.clone(), *config)
);
let size = vec2f(config.width.as_f32(), config.height.as_f32());
let mut image = Image::new(asset_source, CacheOption::BySize)
.contain()
.first_frame_preview();
let constraint = SizeConstraint::new(vec2f(0., 0.), size);
image.layout(constraint, ctx, app);
self.image_element = Some(Box::new(image));
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let positioned_image = extract_block!(
self.viewport_item,
content,
(block, BlockItem::Image { config, .. }) => block.image(config)
);
let selected = model.offset_in_active_selection(positioned_image.start_char_offset);
let draw_cursor = model.is_selection_head(positioned_image.start_char_offset);
let content_position = positioned_image.content_origin();
let screen_position = ctx.content_to_screen(content_position);
let size = vec2f(
positioned_image.item.width.as_f32(),
positioned_image.item.height.as_f32(),
);
if let Some(ref mut image_element) = self.image_element {
image_element.paint(screen_position, ctx.paint, app);
}
if selected {
let rect_bounds = warpui::geometry::rect::RectF::new(screen_position, size);
ctx.paint
.scene
.draw_rect_with_hit_recording(rect_bounds)
.with_background(model.styles().selection_fill);
}
if draw_cursor {
let end_of_line_position = content_position + vec2f(size.x(), 0.);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(model.styles().cursor_width, size.y()),
CursorData::default(),
model.styles(),
);
}
}
}
@@ -0,0 +1,168 @@
use std::ops::Range;
use warpui::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, ModelHandle,
PaintContext, SizeConstraint, WeakViewHandle,
elements::Point,
event::DispatchedEvent,
geometry::{rect::RectF, vector::Vector2F},
units::IntoPixels,
};
use crate::{
editor::EditorView,
render::{
element::{
DisplayOptions, RenderContext, RenderableBlock, paragraph::RenderableParagraph,
temporary_block::RenderableTemporaryBlock,
},
model::{BlockItem, RenderLineLocation, RenderState},
},
};
pub struct RichTextElementLens<V: EditorView> {
blocks: Option<Vec<Box<dyn RenderableBlock>>>,
line_range: Range<RenderLineLocation>,
pub model: ModelHandle<RenderState>,
display_options: DisplayOptions,
parent_view: WeakViewHandle<V>,
element_size: Option<Vector2F>,
element_origin: Option<Point>,
}
impl<V: EditorView> RichTextElementLens<V> {
pub fn new(
line_range: Range<RenderLineLocation>,
model: ModelHandle<RenderState>,
parent_view: WeakViewHandle<V>,
display_options: DisplayOptions,
) -> Self {
Self {
element_size: None,
element_origin: None,
model,
blocks: None,
parent_view,
display_options,
line_range,
}
}
pub fn blocks(&self) -> Option<&[Box<dyn RenderableBlock>]> {
self.blocks.as_deref()
}
pub fn starting_renderable_block_offset(&self) -> Option<f32> {
self.blocks
.as_ref()
.and_then(|blocks| blocks.first())
.map(|block| block.viewport_item().content_offset.as_f32())
}
}
impl<V: EditorView> Element for RichTextElementLens<V> {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
let model = self.model.as_ref(app);
let mut total_height = 0.;
let blocks =
model.blocks_in_line_range(self.line_range.clone(), constraint.max.x().into_pixels());
// Only support paragraphs and temporary blocks for now. This should be extensible to more block types in the future.
let mut renderable_blocks: Vec<Box<dyn RenderableBlock>> = blocks
.into_iter()
.filter_map(|(item, block)| match block {
BlockItem::Paragraph(_) => Some(RenderableParagraph::new(item).finish()),
BlockItem::TemporaryBlock {
decoration,
text_decoration,
..
} => {
Some(RenderableTemporaryBlock::new(item, decoration, text_decoration).finish())
}
_ => None, /* other block types not supported */
})
.collect();
for block in renderable_blocks.iter_mut() {
block.layout(model, ctx, app);
total_height += block.viewport_item().content_size.y();
}
self.blocks = Some(renderable_blocks);
let size = Vector2F::new(constraint.max.x(), total_height);
self.element_size = Some(size);
size
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
for block in self.blocks.as_mut().unwrap().iter_mut() {
block.after_layout(ctx, app);
}
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
let parent = match self.parent_view.upgrade(app) {
Some(handle) => handle.as_ref(app),
None => {
// TODO: This should really have been an error. But currently in code review it's possible
// for the parent editor view to be dropped before the lens element.
log::debug!("Parent rich-text editor view dropped before paint");
return;
}
};
let model = self.model.as_ref(app);
let viewport_size = self.element_size.unwrap();
self.element_origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
let content_bounds = RectF::new(origin, viewport_size);
// "Mock" the scroll top to be the first block's content offset here so the start of line range is displayed
// top of the element.
let scroll_top = self.starting_renderable_block_offset().unwrap_or(0.);
let mut ctx = RenderContext::new(
content_bounds,
self.display_options.focused,
self.display_options.editable,
false, /* no cursor blink */
Default::default(), /* no cursor type */
parent.text_decorations(
model.viewport_charoffset_range(),
model.next_render_buffer_version(),
app,
),
scroll_top,
viewport_size,
model,
ctx,
None,
&[],
);
for block in self.blocks.as_mut().unwrap().iter_mut() {
block.paint(model, &mut ctx, app);
}
}
fn size(&self) -> Option<Vector2F> {
self.element_size
}
fn origin(&self) -> Option<Point> {
self.element_origin
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
}
+119
View File
@@ -0,0 +1,119 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{Align, CacheOption, CornerRadius, Image, Radius, Text},
geometry::vector::vec2f,
};
use crate::{
extract_block,
render::{
element::paint::CursorData,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{CursorDisplayType, RenderContext, RenderableBlock};
pub struct RenderableMermaidDiagram {
viewport_item: ViewportItem,
image_element: Option<Box<dyn Element>>,
}
impl RenderableMermaidDiagram {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
image_element: None,
}
}
}
impl RenderableBlock for RenderableMermaidDiagram {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, model: &RenderState, ctx: &mut warpui::LayoutContext, app: &AppContext) {
let content = model.content();
let (asset_source, config) = extract_block!(
self.viewport_item,
content,
(_block, BlockItem::MermaidDiagram { asset_source, config, .. }) => (asset_source.clone(), *config)
);
let code_text = model.styles().code_text;
let placeholder = Align::new(
Text::new(
"Rendering Mermaid diagram…",
code_text.font_family,
code_text.font_size,
)
.with_color(model.styles().placeholder_color)
.with_line_height_ratio(code_text.line_height_ratio)
.soft_wrap(false)
.finish(),
)
.finish();
let size = vec2f(config.width.as_f32(), config.height.as_f32());
let mut image = Image::new(asset_source, CacheOption::BySize)
.contain()
.before_load(placeholder);
image.layout(SizeConstraint::strict(size), ctx, app);
self.image_element = Some(Box::new(image));
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
let content = model.content();
let (start_offset, end_offset) = extract_block!(
self.viewport_item,
content,
(block, BlockItem::MermaidDiagram { .. }) => (block.start_char_offset, block.end_char_offset())
);
let visible_rect = self.viewport_item.visible_bounds(ctx);
let content_rect = self.viewport_item.content_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(visible_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(model.styles().code_border)
.with_background(model.styles().code_background);
if let Some(ref mut image_element) = self.image_element {
image_element.paint(content_rect.origin(), ctx.paint, app);
}
let selected = model
.selections()
.iter()
.any(|selection| selection.start() < end_offset && selection.end() + 1 > start_offset);
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(content_rect)
.with_background(model.styles().selection_fill);
}
if model.is_selection_head(start_offset) {
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
content_rect.origin(),
vec2f(
model.styles().cursor_width,
self.viewport_item.content_size.y(),
),
CursorData::default(),
model.styles(),
);
}
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
if let Some(ref mut image_element) = self.image_element {
image_element.after_layout(ctx, app);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
use std::sync::Arc;
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::{
layout::TextLayout,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use warpui::elements::ListIndentLevel;
use warpui::{geometry::vector::vec2f, text_layout::TextFrame};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
pub struct RenderableOrderedListItem {
viewport_item: ViewportItem,
number: String,
rendered_number: Option<Arc<TextFrame>>,
placeholder: BlockPlaceholder,
}
impl RenderableOrderedListItem {
pub fn new(indent_level: ListIndentLevel, viewport_item: ViewportItem, number: usize) -> Self {
let number = indent_level.list_number_string(number);
Self {
viewport_item,
number,
rendered_number: None,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableOrderedListItem {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
let text_layout = TextLayout::from_layout_context(ctx, app, model);
let block_style = BufferBlockStyle::OrderedList {
indent_level: ListIndentLevel::One,
number: None,
};
let paragraph_styles = &text_layout.paragraph_styles(&block_style);
let number_text = format!("{}.", self.number);
let style_runs = &[(
0..number_text.chars().count(),
text_layout.style_and_font(paragraph_styles, &Default::default()),
)];
self.rendered_number = Some(text_layout.layout_text(
&number_text,
paragraph_styles,
&self.viewport_item.spacing,
style_runs,
));
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
block_style,
text: "List",
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let paragraph = extract_block!(self.viewport_item, content, (block, BlockItem::OrderedList{ paragraph: inner, ..}) => block.ordered_list(inner));
let text_styling = &model.styles().base_text;
let number = self
.rendered_number
.as_ref()
.expect("Number should be set during layout");
// Position the numeric label in the margin to the left of the item content.
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
let number_origin =
paragraph.content_origin() - vec2f(number.max_width() + space_width, 0.);
ctx.draw_text(number_origin, Default::default(), number, text_styling);
if !self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
ctx.draw_paragraph(&paragraph, text_styling, model);
}
}
}
+345
View File
@@ -0,0 +1,345 @@
//! Utilities for painting rich text.
use std::ops::Range;
use warp_core::ui::appearance::DEFAULT_UI_FONT_SIZE;
use warpui::{
PaintContext,
elements::{CornerRadius, Point, Radius},
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
text_layout::{Line, PaintStyleOverride, TextFrame},
};
use crate::{
editor::TextDecoration,
render::{
layout::line_height,
model::{
Decoration, Paragraph, ParagraphStyles, Positioned, RenderState, RichTextStyles,
saved_positions::SavedPositions,
},
},
};
use string_offset::CharOffset;
use vim::vim::VimMode;
const DEFAULT_BLOCK_CURSOR_WIDTH: f32 = 8.;
/// Cursor display types for vim mode support.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum CursorDisplayType {
#[default]
Bar,
Block,
Underline,
}
/// Cursor data struct for rendering block and underline cursors in vim mode
#[derive(Default)]
pub struct CursorData {
pub block_width: Option<f32>,
pub font_size: Option<f32>,
}
impl CursorData {
/// Unzip cursor data, defaulting to constants if the values are `None`
fn unzip(&self) -> (f32, f32) {
let font_size = self.font_size.unwrap_or(DEFAULT_UI_FONT_SIZE);
let fallback_block_cursor_width =
DEFAULT_BLOCK_CURSOR_WIDTH * (font_size / DEFAULT_UI_FONT_SIZE);
let block_width = self.block_width.unwrap_or(fallback_block_cursor_width);
(font_size, block_width)
}
}
/// Bundle of context needed to render a viewported rich text item.
pub struct RenderContext<'a, 'b> {
/// The on-screen viewport bounds.
pub bounds: RectF,
/// The starting y-offset of content in the current viewport.
pub content_offset: Vector2F,
/// Whether or not the rich text is focused.
pub focused: bool,
/// Whether or not the rich text is editable.
pub editable: bool,
/// Cursor blink state - this is true if cursor blink is disabled _or_ blinking cursors are
/// visible.
blink_on: bool,
/// The cursor type to display
pub cursor_type: CursorDisplayType,
text_decorations: TextDecoration<'a>,
/// Underlying paint context for rendering.
pub paint: &'a mut PaintContext<'b>,
saved_positions: &'a SavedPositions,
pub viewport_size: Vector2F,
/// Current VimMode of the rich text element, if there is one.
pub vim_mode: Option<VimMode>,
/// Vim visual tails - stored cursor positions when entering vim visual mode
pub vim_visual_tails: &'a [CharOffset],
}
impl<'a, 'b> RenderContext<'a, 'b> {
#[allow(clippy::too_many_arguments)]
pub fn new(
bounds: RectF,
focused: bool,
editable: bool,
blink_on: bool,
cursor_type: CursorDisplayType,
text_decorations: TextDecoration<'a>,
scroll_top: f32,
viewport_size: Vector2F,
model: &'a RenderState,
paint: &'a mut PaintContext<'b>,
vim_mode: Option<VimMode>,
vim_visual_tails: &'a [CharOffset],
) -> Self {
Self {
bounds,
// Note that we use the scroll_top directly passed in from the element here because it could
// be updated with the last layout (if the vertical display option is set to grow to max constraint).
// This problem does not exist for vertical scrolls.
content_offset: vec2f(model.viewport().scroll_left().as_f32(), scroll_top),
focused,
editable,
blink_on,
cursor_type,
text_decorations,
paint,
saved_positions: model.saved_positions(),
viewport_size,
vim_mode,
vim_visual_tails,
}
}
pub fn paint_style_override(&self, range: Range<CharOffset>) -> PaintStyleOverride {
self.text_decorations.to_paint_style_override(range)
}
/// Returns the visible bound of the viewport.
pub fn visible_bound(&self) -> RectF {
self.bounds
}
/// Convert a position within the buffer to the corresponding on-screen location.
///
/// Note that the returned location may be out of viewport. The caller is responsible for
/// bounds checking.
pub fn content_to_screen(&self, position: Vector2F) -> Vector2F {
let viewport_relative = position - self.content_offset;
self.bounds.origin() + viewport_relative
}
/// Adjust a buffer-relative rectangle to its on-screen origin.
///
/// Note that the returned rectangle may be out of viewport. The caller is responsible for
/// bounds checking.
pub fn content_rect_to_screen(&self, rect: RectF) -> RectF {
RectF::new(self.content_to_screen(rect.origin()), rect.size())
}
/// Paint a paragraph of text, along with any decorations indicated by the rendering model.
pub fn draw_paragraph(
&mut self,
paragraph: &Positioned<Paragraph>,
style: &ParagraphStyles,
state: &RenderState,
) {
let paint_style_override =
self.paint_style_override(paragraph.start_char_offset..paragraph.end_char_offset());
self.draw_text(
paragraph.content_origin(),
paint_style_override,
paragraph.item.frame(),
style,
);
paragraph.draw_selection(state, self);
self.draw_text_decorations(paragraph, state.decorations().text(), state);
}
/// Helper to draw text decorations over a paragraph. The decorations must be sorted by **end**
/// offset.
fn draw_text_decorations(
&mut self,
paragraph: &Positioned<Paragraph>,
decorations: &[Decoration],
state: &RenderState,
) {
let paragraph_end = paragraph.end_char_offset();
// Because decorations are sorted by their end offset, we binary search to find the last
// decoration that overlaps with the paragraph.
let last_decoration =
decorations.partition_point(|decoration| decoration.end <= paragraph_end);
for decoration in decorations[..last_decoration].iter().rev() {
if decoration.end <= paragraph.start_char_offset {
// Because we're looping backwards, this and all earlier decorations cannot apply
// to the paragraph.
break;
}
if let Some(highlight) = decoration.background {
paragraph.draw_highlight(
decoration.start,
decoration.end,
highlight.into(),
self,
state.max_line(),
);
}
if let Some(color) = decoration.dashed_underline {
paragraph.draw_dashed_underline(decoration.start, decoration.end, color, self);
}
}
}
/// Paint the portion of a [`TextFrame`] that is within the viewport.
///
/// The `content_position` is the position of the frame relative to the buffer's origin
/// (that is, the same as [`Positioned::content_origin`]).
pub fn draw_text(
&mut self,
content_position: Vector2F,
paint_style_override: PaintStyleOverride,
frame: &TextFrame,
style: &ParagraphStyles,
) {
// The origin of the item on the screen, which all lines are painted relative to.
let mut render_origin = self.content_to_screen(content_position);
for (index, line) in frame.lines().iter().enumerate() {
// Order matters for these tests. First, we check if we've
// finished rendering all in-viewport lines, which is true
// once the current line's starting offset is past the max
// render height. Then, we figure out where the current line
// starts and ends. If the end of the line is outside the viewport,
// skip past it. It's important that we still update the origin, otherwise
// we skip the whole paragraph.
if render_origin.y() > self.bounds.max_y() {
// This line is completely below the viewport, and so
// all subsequent lines will be as well.
log::trace!("Lines {index}+ are below viewport, skipping");
break;
}
let line_origin = render_origin;
// Add the line height to the render origin so that we know where the next line begins.
// Conveniently for viewporting, this also tells us where the current line ends.
render_origin.set_y(render_origin.y() + line_height(line));
if render_origin.y() < self.bounds.min_y() {
// This line is completely above the viewport. Skip past
// it until we get to an in-viewport line.
log::trace!("Line {index} is above the viewport, skipping");
continue;
}
log::trace!("Painting line {index}");
line.paint(
RectF::from_points(line_origin, self.bounds.lower_right()),
&paint_style_override,
style.text_color,
self.paint.font_cache,
self.paint.scene,
);
}
}
/// Paint a single [`Line`] of text, if it's within the viewport.
///
/// The `content_position` is the position of the line relative to the buffer's origin (that
/// is, the same as [`Positioned::content_origin`]).
pub fn draw_line(&mut self, content_position: Vector2F, line: &Line, style: &ParagraphStyles) {
// This is a simplified version of the inner loop of [`Self::draw_text`], since there's
// only a single line to consider.
let render_origin = self.content_to_screen(content_position);
if render_origin.y() > self.bounds.max_y() {
log::trace!("Line is below viewport, skipping");
return;
}
if render_origin.y() + line_height(line) < self.bounds.min_y() {
log::trace!("Line is above viewport, skipping");
return;
}
log::trace!("Painting single line");
line.paint(
RectF::from_points(render_origin, self.bounds.lower_right()),
&Default::default(),
style.text_color,
self.paint.font_cache,
self.paint.scene,
);
}
/// Draws a cursor at the content position and save it into the position cache.
///
/// If cursors are not visible, they will be saved but not drawn.
pub fn draw_and_save_cursor(
&mut self,
cursor_display_type: CursorDisplayType,
content_position: Vector2F,
size: Vector2F,
cursor_data: CursorData,
styles: &RichTextStyles,
) {
let (font_size, block_width) = cursor_data.unzip();
let height = size.y();
let cursor_size = match cursor_display_type {
CursorDisplayType::Bar => size,
CursorDisplayType::Block => vec2f(block_width, height),
CursorDisplayType::Underline => vec2f(block_width, height - font_size),
};
let cursor_origin = match cursor_display_type {
CursorDisplayType::Bar => {
// Center the cursor on its origin. This reduces the amount of overlap with glyphs,
// especially for wider cursors.
self.content_to_screen(content_position) - vec2f(size.x() / 2., 0.)
}
CursorDisplayType::Block => self.content_to_screen(content_position),
CursorDisplayType::Underline => {
self.content_to_screen(content_position) + vec2f(0., font_size)
}
};
let bounds = RectF::new(cursor_origin, cursor_size);
let cursor_corner_radius = match cursor_display_type {
CursorDisplayType::Block | CursorDisplayType::Underline => Radius::Pixels(0.),
_ => Radius::Percentage(50.),
};
if self.cursors_visible() {
self.paint
.scene
.draw_rect_with_hit_recording(bounds)
.with_background(styles.cursor_fill)
.with_corner_radius(CornerRadius::with_all(cursor_corner_radius));
}
// The cursor should only exist at one location, so we can save it here.
self.paint
.position_cache
.cache_position_indefinitely(self.saved_positions.cursor_id(), bounds);
}
/// Returns `true` if cursors should be visible.
fn cursors_visible(&self) -> bool {
self.editable && self.focused && self.blink_on
}
/// Tests whether or not any portion of the given rectangle is visible at the current z-index.
pub fn is_visible(&self, rect: RectF) -> bool {
let origin = Point::from_vec2f(rect.origin(), self.paint.scene.z_index());
self.paint.scene.visible_rect(origin, rect.size()).is_some()
}
}
@@ -0,0 +1,76 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
/// The placeholder text to show in empty plain-text blocks.
pub(super) const PARAGRAPH_PLACEHOLDER_TEXT: &str =
"Type text or Markdown, or '/' to insert content";
pub(super) const PARAGRAPH_PLACEHOLDER_TEXT_WITHOUT_SLASH: &str = "Type text or Markdown";
pub fn paragraph_placeholder_text(slash_menu_enabled: bool) -> &'static str {
if slash_menu_enabled {
PARAGRAPH_PLACEHOLDER_TEXT
} else {
PARAGRAPH_PLACEHOLDER_TEXT_WITHOUT_SLASH
}
}
/// [`RenderableBlock`] implementation for `Paragraph` blocks.
pub struct RenderableParagraph {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl RenderableParagraph {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(false),
}
}
}
impl RenderableBlock for RenderableParagraph {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
text: paragraph_placeholder_text(model.selections().len() == 1),
block_style: BufferBlockStyle::PlainText,
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let paragraph = extract_block!(self.viewport_item, content, (block, BlockItem::Paragraph(inner)) => block.paragraph(inner));
if self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
return;
}
let paragraph_styles = &model.styles().base_text;
ctx.draw_paragraph(&paragraph, paragraph_styles, model);
}
}
@@ -0,0 +1,143 @@
use std::sync::Arc;
use warpui::{
AppContext, LayoutContext,
geometry::vector::{Vector2F, vec2f},
text_layout::Line,
};
use crate::{
content::text::BufferBlockStyle,
render::{
element::paint::CursorDisplayType,
layout::{TextLayout, line_height},
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{CursorData, RenderContext};
/// Ghost/placeholder text that's shown in an empty block to provide context.
pub struct BlockPlaceholder {
show_always: bool,
state: State,
}
enum State {
PendingLayout,
LaidOut {
/// Whether or not the block contains the text cursor.
contains_cursor: bool,
/// The placeholder text, laid out as a single clipped line.
line: Arc<Line>,
block_style: BufferBlockStyle,
},
NotShown,
}
impl BlockPlaceholder {
pub fn new(show_always: bool) -> Self {
Self {
show_always,
state: State::PendingLayout,
}
}
/// Lay out the placeholder, if necessary.
pub fn layout<'a, F>(
&mut self,
item: &ViewportItem,
model: &RenderState,
ctx: &mut LayoutContext,
app: &AppContext,
options: F,
) where
F: FnOnce(&BlockItem) -> Options<'a>,
{
debug_assert!(
matches!(self.state, State::PendingLayout),
"Placeholder laid out multiple times"
);
self.state = State::NotShown;
let content = model.content();
let block_offset = item.block_offset();
let block = match content.block_at_offset(block_offset) {
Some(block) if block.item.is_empty() => block,
_ => {
// Placeholders are _never_ shown if the block has user content.
return;
}
};
let contains_cursor = model.selections().iter().any(|selection| {
selection
.single_cursor()
.is_some_and(|cursor| block.contains_content(cursor))
});
if !model.styles().show_placeholder_text_on_empty_block
|| (!self.show_always && !contains_cursor)
{
// If the cursor isn't in this block, don't lay out the placeholder.
return;
}
let layout = TextLayout::from_layout_context(ctx, app, model);
let options = options(block.item);
self.state = State::LaidOut {
line: layout.layout_placeholder(options.text, &options.block_style, &item.spacing),
block_style: options.block_style,
contains_cursor,
};
}
/// Paint this placeholder at the original block's origin. Returns `false` if there is no
/// placeholder, and the regular content should be shown.
pub fn paint(
&self,
content_origin: Vector2F,
model: &RenderState,
ctx: &mut RenderContext,
) -> bool {
match &self.state {
State::NotShown => false,
State::PendingLayout => {
log::warn!("Tried to paint placeholder before layout");
false
}
State::LaidOut {
contains_cursor,
line,
block_style,
} => {
if ctx.editable && (self.show_always || ctx.focused) {
let paragraph_styles = model.styles().paragraph_styles(block_style);
ctx.draw_line(content_origin, line, &paragraph_styles);
}
// If this placeholder contains the cursor, we must draw it, regardless of
// focus (since the cursor positions other UI elements).
if *contains_cursor {
let cursor_size = vec2f(model.styles().cursor_width, line_height(line));
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
content_origin,
cursor_size,
CursorData::default(),
model.styles(),
);
}
true
}
}
}
}
/// Options for displaying a placeholder.
pub struct Options<'a> {
/// The placeholder text.
pub text: &'a str,
/// Block-level styling.
pub block_style: BufferBlockStyle,
}
@@ -0,0 +1,118 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{Border, CornerRadius, Empty, Radius},
geometry::vector::vec2f,
};
use crate::{
editor::RunnableCommandModel,
extract_block,
render::{
BLOCK_FOOTER_HEIGHT,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
/// [`RenderableBlock`] implementation for runnable command blocks.
pub struct RenderableRunnableCommand {
viewport_item: ViewportItem,
footer: Box<dyn Element>,
border: Option<Border>,
}
impl RenderableRunnableCommand {
pub fn new(
viewport_item: ViewportItem,
model: Option<&dyn RunnableCommandModel>,
editor_is_focused: bool,
ctx: &AppContext,
) -> Self {
let border = model.as_ref().and_then(|model| model.border(ctx));
let footer = match model {
Some(model) => model.render_block_footer(editor_is_focused, ctx),
None => Empty::new().finish(),
};
Self {
viewport_item,
footer,
border,
}
}
}
impl RenderableBlock for RenderableRunnableCommand {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, _model: &RenderState, ctx: &mut warpui::LayoutContext, app: &AppContext) {
self.footer.layout(
SizeConstraint::strict(vec2f(
self.viewport_item.content_size.x(),
BLOCK_FOOTER_HEIGHT,
)),
ctx,
app,
);
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
let content = model.content();
let code_block = extract_block!(self.viewport_item, content, (block, BlockItem::RunnableCodeBlock{code_block_type: _, paragraph_block}) => block.code_block(paragraph_block));
let styles = model.styles();
let code_style = &styles.code_text;
let border = if ctx.focused {
self.border.unwrap_or(styles.code_border)
} else {
styles.code_border
};
let background_rect = self.viewport_item.visible_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(background_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(border)
.with_background(model.styles().code_background);
for paragraph in code_block.paragraphs() {
ctx.draw_paragraph(&paragraph, code_style, model);
}
// Place the button at a higher z-index for event handling. See the comment on
// `RichTextElement::content_z_index` for context.
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
// Position the block footer right below the content area, flush with its right-hand edge.
// This gives the footer some padding relative to the visible area with a background.
let content_rect = self.viewport_item.content_bounds(ctx);
let button_origin = content_rect.lower_right()
- vec2f(
self.footer.size().expect("Footer should be laid out").x(),
0.,
);
self.footer.paint(button_origin, ctx.paint, app);
ctx.paint.scene.stop_layer();
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
self.footer.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.footer.dispatch_event(event, ctx, app)
}
}
+707
View File
@@ -0,0 +1,707 @@
use std::ops::Range;
use string_offset::CharOffset;
use warpui::{
AppContext, ClipBounds, Event, EventContext,
elements::{
Axis, CornerRadius, DEFAULT_SCROLL_WHEEL_PIXELS_PER_LINE, Radius, ScrollData,
ScrollbarAppearance, ScrollbarGeometry, ScrollbarWidth, compute_scrollbar_geometry,
project_scroll_delta_by_sensitivity, scroll_delta_for_pointer_movement,
},
event::DispatchedEvent,
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::{IntoPixels, Pixels},
};
use crate::render::model::table_offset_map::CellAtOffset;
use crate::{
extract_block,
render::model::{
BlockItem, LaidOutTable, RenderState, RenderedSelection, TableStyle, viewport::ViewportItem,
},
};
use super::{
RenderContext, RenderableBlock,
paint::{CursorData, CursorDisplayType},
};
const TABLE_BORDER_WIDTH: f32 = 1.0;
const TABLE_SCROLL_SENSITIVITY: f32 = 1.0;
pub struct RenderableTable {
viewport_item: ViewportItem,
viewport_bounds: Option<RectF>,
scrollbar: Option<ScrollbarGeometry>,
}
#[cfg(test)]
#[derive(Debug, Clone)]
struct TableLayoutReport {
column_widths: Vec<f32>,
column_lefts: Vec<f32>,
header_height: f32,
row_heights: Vec<f32>,
row_tops: Vec<f32>,
total_height: f32,
}
#[cfg(test)]
fn model_table_layout_report(laid_out_table: &LaidOutTable) -> TableLayoutReport {
let column_widths = laid_out_table
.column_widths
.iter()
.map(|width| width.as_f32())
.collect::<Vec<_>>();
let mut running_left = 0.0;
let column_lefts = column_widths
.iter()
.map(|width| {
let left = running_left;
running_left += *width;
left
})
.collect::<Vec<_>>();
let row_heights = laid_out_table
.row_heights
.iter()
.map(|height| height.as_f32())
.collect::<Vec<_>>();
let header_height = row_heights.first().copied().unwrap_or_default();
let body_row_heights = row_heights.iter().copied().skip(1).collect::<Vec<_>>();
let mut running_top = 0.0;
let row_tops = body_row_heights
.iter()
.map(|height| {
let top = running_top;
running_top += *height;
top
})
.collect::<Vec<_>>();
TableLayoutReport {
column_widths,
column_lefts,
header_height,
row_heights: body_row_heights,
row_tops,
total_height: laid_out_table.total_height.as_f32(),
}
}
#[cfg(test)]
fn row_geometry_from_layout_report(report: &TableLayoutReport) -> (Vec<f32>, Vec<f32>) {
let mut row_tops = Vec::with_capacity(1 + report.row_tops.len());
let mut row_heights = Vec::with_capacity(1 + report.row_heights.len());
row_tops.push(0.0);
row_heights.push(report.header_height);
for (row_top, row_height) in report.row_tops.iter().zip(report.row_heights.iter()) {
row_tops.push(report.header_height + row_top);
row_heights.push(*row_height);
}
(row_tops, row_heights)
}
impl RenderableTable {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
viewport_bounds: None,
scrollbar: None,
}
}
}
impl RenderableBlock for RenderableTable {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, _: &RenderState, _: &mut warpui::LayoutContext, _: &AppContext) {}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &AppContext) {
let content = model.content();
let positioned_table = extract_block!(
self.viewport_item,
content,
(block, BlockItem::Table(laid_out_table)) => block.table(laid_out_table)
);
let content_position = positioned_table.content_origin();
let table_start = positioned_table.start_char_offset;
let laid_out_table = positioned_table.item;
let style = &model.styles().table_style;
let viewport_width = table_viewport_width(laid_out_table, &self.viewport_item);
let viewport_bounds = RectF::new(
ctx.content_to_screen(content_position),
vec2f(viewport_width.as_f32(), laid_out_table.height().as_f32()),
);
let visible_content_position =
content_position - vec2f(laid_out_table.scroll_left().as_f32(), 0.0);
let screen_position = ctx.content_to_screen(visible_content_position);
self.viewport_bounds = Some(viewport_bounds);
self.scrollbar = table_scrollbar(laid_out_table, viewport_bounds, viewport_width);
if self.scrollbar.is_none() {
laid_out_table.clear_scrollbar_interaction_state();
}
ctx.paint
.scene
.start_layer(ClipBounds::BoundedByActiveLayerAnd(viewport_bounds));
ctx.paint.scene.set_active_layer_click_through();
paint_backgrounds(laid_out_table, style, screen_position, ctx);
paint_cell_text(laid_out_table, style, screen_position, ctx);
paint_borders(laid_out_table, style, screen_position, ctx);
paint_selection(model, ctx, table_start, laid_out_table, screen_position);
paint_cursor(
model,
ctx,
table_start,
laid_out_table,
visible_content_position,
screen_position,
);
paint_scrollbar(
self.scrollbar,
style,
laid_out_table.scrollbar_hovered() || laid_out_table.scrollbar_drag_state().is_some(),
ctx,
);
ctx.paint.scene.stop_layer();
}
fn dispatch_event(
&mut self,
model: &RenderState,
event: &DispatchedEvent,
ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
let content = model.content();
let Some(block) = content.block_at_offset(self.viewport_item.block_offset()) else {
return false;
};
let BlockItem::Table(laid_out_table) = block.item else {
return false;
};
let viewport_width = table_viewport_width(laid_out_table, &self.viewport_item);
match event.raw_event() {
Event::LeftMouseDown { position, .. } => {
if let Some(scrollbar) = self.scrollbar {
let thumb_hit = scrollbar.thumb_bounds.contains_point(*position);
let track_hit = scrollbar.track_bounds.contains_point(*position);
if thumb_hit {
laid_out_table.start_scrollbar_drag(
position.x().into_pixels(),
table_scroll_data(laid_out_table, viewport_width),
);
ctx.notify();
return true;
}
if track_hit {
let changed = laid_out_table.scroll_horizontally(
scroll_delta_for_pointer_movement(
scrollbar.thumb_center_along(Axis::Horizontal),
position.x().into_pixels(),
table_scroll_data(laid_out_table, viewport_width),
),
viewport_width,
);
if changed {
ctx.notify();
}
return true;
}
}
false
}
Event::LeftMouseDragged { position, .. } => {
let Some(drag_state) = laid_out_table.scrollbar_drag_state() else {
return false;
};
let scroll_delta = scroll_delta_for_pointer_movement(
drag_state.start_position_x,
position.x().into_pixels(),
drag_state.scroll_data,
);
let changed = laid_out_table
.set_scroll_left(drag_state.start_scroll_left - scroll_delta, viewport_width);
if changed {
ctx.notify();
}
true
}
Event::LeftMouseUp { .. } => {
let had_drag = laid_out_table.end_scrollbar_drag();
if had_drag {
ctx.notify();
}
had_drag
}
Event::MouseMoved { position, .. } => {
let hovered = self
.scrollbar
.is_some_and(|scrollbar| scrollbar.thumb_bounds.contains_point(*position));
if laid_out_table.set_scrollbar_hovered(hovered) {
ctx.notify();
}
// MouseMoved should never be consumed here so that downstream handlers
// (hover-link detection, cursor changes, etc.) still receive the event even
// when the pointer is over the scrollbar thumb.
false
}
Event::ScrollWheel {
position,
delta,
precise,
modifiers,
} if !modifiers.ctrl => {
let Some(bounds) = self.viewport_bounds else {
return false;
};
if !bounds.contains_point(*position)
|| laid_out_table.max_scroll_left(viewport_width) <= Pixels::zero()
{
return false;
}
let Some(horizontal_delta) =
table_horizontal_scroll_delta(*delta, *precise, modifiers.shift)
else {
return false;
};
let changed = laid_out_table.scroll_horizontally(horizontal_delta, viewport_width);
if changed {
ctx.notify();
}
// If the table is already pinned at the scroll edge in the direction of the
// delta, returning `false` lets the event fall through to the surrounding
// vertical scroller instead of sticking at the horizontal edge.
changed
}
_ => false,
}
}
}
fn table_viewport_width(laid_out_table: &LaidOutTable, viewport_item: &ViewportItem) -> Pixels {
laid_out_table.viewport_width(Pixels::new(viewport_item.content_size.x()))
}
fn table_scroll_data(laid_out_table: &LaidOutTable, viewport_width: Pixels) -> ScrollData {
ScrollData {
scroll_start: laid_out_table.scroll_left(),
visible_px: laid_out_table.viewport_width(viewport_width),
total_size: laid_out_table.width(),
}
}
fn table_horizontal_scroll_delta(delta: Vector2F, precise: bool, shift: bool) -> Option<Pixels> {
let delta = if shift && delta.x().abs() <= f32::EPSILON {
vec2f(delta.y(), 0.0)
} else {
delta
};
let projected_delta = project_scroll_delta_by_sensitivity(delta, TABLE_SCROLL_SENSITIVITY);
let horizontal_delta = projected_delta.x();
(horizontal_delta.abs() > f32::EPSILON).then_some(Pixels::new(if precise {
horizontal_delta
} else {
horizontal_delta * DEFAULT_SCROLL_WHEEL_PIXELS_PER_LINE
}))
}
fn table_scrollbar(
laid_out_table: &LaidOutTable,
viewport_bounds: RectF,
viewport_width: Pixels,
) -> Option<ScrollbarGeometry> {
let scrollbar = compute_scrollbar_geometry(
Axis::Horizontal,
viewport_bounds.origin(),
viewport_bounds.size(),
table_scroll_data(laid_out_table, viewport_width),
ScrollbarAppearance::new(ScrollbarWidth::Auto, true),
);
scrollbar.has_thumb().then_some(scrollbar)
}
fn paint_scrollbar(
scrollbar: Option<ScrollbarGeometry>,
style: &TableStyle,
active: bool,
ctx: &mut RenderContext,
) {
let Some(scrollbar) = scrollbar else {
return;
};
ctx.paint
.scene
.draw_rect_without_hit_recording(scrollbar.thumb_bounds)
.with_background(if active {
style.scrollbar_active_thumb_color
} else {
style.scrollbar_nonactive_thumb_color
})
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.0)));
}
fn paint_backgrounds(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let total_rows = 1 + laid_out_table.table.rows.len();
for row in 0..total_rows {
let y = row_y_offset(laid_out_table, row);
let h = row_height(laid_out_table, row);
let bg = if row == 0 {
style.header_background
} else if let Some(alt) = style.alternate_row_background {
if row % 2 == 0 {
alt
} else {
style.cell_background
}
} else {
style.cell_background
};
ctx.paint
.scene
.draw_rect_without_hit_recording(RectF::new(
screen_position + vec2f(0.0, y),
vec2f(laid_out_table.width().as_f32(), h),
))
.with_background(bg);
}
}
fn paint_cell_text(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let total_rows = 1 + laid_out_table.table.rows.len();
let num_cols = laid_out_table.column_widths.len();
for row in 0..total_rows {
for col in 0..num_cols {
let is_header = row == 0;
let text_color = if is_header {
style.header_text_color
} else {
style.text_color
};
let Some(frame) = laid_out_table
.cell_text_frames
.get(row)
.and_then(|row_frames| row_frames.get(col))
else {
continue;
};
let cell_content_width = laid_out_table
.column_widths
.get(col)
.map(|w| w.as_f32())
.unwrap_or(0.0)
- style.cell_padding * 2.0;
let cell_content_width = cell_content_width.max(0.0);
let bounds = RectF::new(
screen_position + laid_out_table.cell_content_origin(row, col),
vec2f(cell_content_width, f32::MAX),
);
frame.paint(
bounds,
&Default::default(),
text_color,
ctx.paint.scene,
ctx.paint.font_cache,
);
}
}
}
fn paint_borders(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let bw = TABLE_BORDER_WIDTH;
let table_w = laid_out_table.width().as_f32();
let table_h = laid_out_table.total_height.as_f32();
let bc = style.border_color;
let draw = |ctx: &mut RenderContext, rect: RectF| {
ctx.paint
.scene
.draw_rect_without_hit_recording(rect)
.with_background(bc);
};
if style.outer_border {
draw(ctx, RectF::new(screen_position, vec2f(table_w, bw)));
draw(
ctx,
RectF::new(
screen_position + vec2f(0.0, table_h - bw),
vec2f(table_w, bw),
),
);
draw(ctx, RectF::new(screen_position, vec2f(bw, table_h)));
draw(
ctx,
RectF::new(
screen_position + vec2f(table_w - bw, 0.0),
vec2f(bw, table_h),
),
);
}
let total_rows = 1 + laid_out_table.table.rows.len();
if style.row_dividers {
for row in 1..total_rows {
let y = row_y_offset(laid_out_table, row);
draw(
ctx,
RectF::new(screen_position + vec2f(0.0, y), vec2f(table_w, bw)),
);
}
}
if style.column_dividers {
let mut col_x = 0.0f32;
for col in 0..laid_out_table.column_widths.len().saturating_sub(1) {
col_x += laid_out_table.column_widths[col].as_f32();
draw(
ctx,
RectF::new(screen_position + vec2f(col_x, 0.0), vec2f(bw, table_h)),
);
}
}
}
fn paint_selection(
model: &RenderState,
ctx: &mut RenderContext,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
screen_position: Vector2F,
) {
let selections = model.selections();
for selection in selections.iter() {
let Some(selection_range) =
table_selection_relative_range(selection, table_start, laid_out_table)
else {
continue;
};
let relative_start = selection_range.start;
let relative_end = selection_range.end;
let affected_cells = laid_out_table
.offset_map
.cells_in_range(relative_start, relative_end);
for cell in affected_cells {
let Some(cell_offset_map) = laid_out_table
.cell_offset_maps
.get(cell.row)
.and_then(|row_maps| row_maps.get(cell.col))
else {
continue;
};
let cell_char_start_in_table = cell.start;
let cell_char_end_in_table = cell.end;
let sel_start_in_cell = cell_offset_map
.source_to_rendered(if relative_start > cell_char_start_in_table {
relative_start - cell_char_start_in_table
} else {
CharOffset::zero()
})
.as_usize();
let sel_end_in_cell = cell_offset_map
.source_to_rendered(if relative_end < cell_char_end_in_table {
relative_end - cell_char_start_in_table
} else {
cell_char_end_in_table - cell_char_start_in_table
})
.as_usize();
let cell_layout = laid_out_table
.cell_layouts
.get(cell.row)
.and_then(|row_layouts| row_layouts.get(cell.col));
let cell_content_origin =
screen_position + laid_out_table.cell_content_origin(cell.row, cell.col);
let Some(layout) = cell_layout else {
continue;
};
let start_line = layout
.line_at_char_offset(CharOffset::from(sel_start_in_cell))
.unwrap_or(0);
let end_line = layout
.line_at_char_offset(CharOffset::from(sel_end_in_cell.saturating_sub(1)))
.unwrap_or(start_line);
for line_idx in start_line..=end_line {
let line_y = layout.line_y_offsets.get(line_idx).copied().unwrap_or(0.0);
let line_height = layout.line_heights.get(line_idx).copied().unwrap_or(20.0);
let line_start_x = if line_idx == start_line {
layout.x_for_char_in_line(line_idx, sel_start_in_cell)
} else {
0.0
};
let line_end_x = if line_idx == end_line {
layout.x_for_char_in_line(line_idx, sel_end_in_cell)
} else {
let range = layout.line_char_ranges.get(line_idx);
layout
.x_for_char_in_line(line_idx, range.map(|r| r.end.as_usize()).unwrap_or(0))
};
let sel_rect = RectF::new(
vec2f(
cell_content_origin.x() + line_start_x,
cell_content_origin.y() + line_y,
),
vec2f((line_end_x - line_start_x).max(1.0), line_height),
);
ctx.paint
.scene
.draw_rect_without_hit_recording(sel_rect)
.with_background(model.styles().selection_fill);
}
}
}
}
fn paint_cursor(
model: &RenderState,
ctx: &mut RenderContext,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
content_position: Vector2F,
screen_position: Vector2F,
) {
let selections = model.selections();
for selection in selections.iter() {
let Some(relative_offset) =
table_cursor_relative_offset(selection, table_start, laid_out_table)
else {
continue;
};
if let Some(CellAtOffset {
row,
col,
offset_in_cell,
}) = laid_out_table.offset_map.cell_at_offset(relative_offset)
{
let Some(cell_offset_map) = laid_out_table
.cell_offset_maps
.get(row)
.and_then(|row_maps| row_maps.get(col))
else {
continue;
};
let cell_content_origin =
screen_position + laid_out_table.cell_content_origin(row, col);
let Some(cell_layout) = laid_out_table
.cell_layouts
.get(row)
.and_then(|row_layouts| row_layouts.get(col))
else {
continue;
};
let rendered_offset_in_cell = cell_offset_map.source_to_rendered(offset_in_cell);
let offset_in_cell_usize = rendered_offset_in_cell.as_usize();
let line_idx = cell_layout
.line_at_char_offset(rendered_offset_in_cell)
.unwrap_or(0);
let cursor_y_offset = cell_layout
.line_y_offsets
.get(line_idx)
.copied()
.unwrap_or(0.0);
let cursor_height = cell_layout
.line_heights
.get(line_idx)
.copied()
.unwrap_or(20.0);
let cursor_x_offset = cell_layout.x_for_char_in_line(line_idx, offset_in_cell_usize);
let cursor_screen_x = cell_content_origin.x() + cursor_x_offset;
let cursor_screen_y = cell_content_origin.y() + cursor_y_offset;
let cursor_content_x = content_position.x() + (cursor_screen_x - screen_position.x());
let cursor_content_y = content_position.y() + (cursor_screen_y - screen_position.y());
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
vec2f(cursor_content_x, cursor_content_y),
vec2f(model.styles().cursor_width, cursor_height),
CursorData::default(),
model.styles(),
);
}
}
}
fn table_selection_relative_range(
selection: &RenderedSelection,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
) -> Option<Range<CharOffset>> {
let table_end = table_start + laid_out_table.content_length();
let start = selection.start().max(table_start);
let end = selection.end().min(table_end);
(start < end).then(|| (start - table_start)..(end - table_start))
}
fn table_cursor_relative_offset(
selection: &RenderedSelection,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
) -> Option<CharOffset> {
let table_end = table_start + laid_out_table.content_length();
let head = selection.head;
(head >= table_start && head < table_end).then(|| head - table_start)
}
fn row_y_offset(laid_out_table: &LaidOutTable, row: usize) -> f32 {
laid_out_table
.row_y_offsets
.get(row)
.copied()
.unwrap_or(0.0)
}
fn row_height(laid_out_table: &LaidOutTable, row: usize) -> f32 {
laid_out_table
.row_heights
.get(row)
.map(|h| h.as_f32())
.unwrap_or(20.0)
}
#[cfg(test)]
#[path = "table_tests.rs"]
mod tests;
@@ -0,0 +1,451 @@
use pathfinder_color::ColorU;
use std::{cell::Cell, sync::Arc};
use string_offset::CharOffset;
use warpui::{
elements::{Axis, scroll_delta_for_pointer_movement},
fonts::FamilyId,
geometry::{rect::RectF, vector::vec2f},
text_layout::TextFrame,
units::{IntoPixels, Pixels},
};
use crate::{
content::text::{FormattedTable, table_cell_offset_maps},
render::{
element::table::{
model_table_layout_report, row_geometry_from_layout_report,
table_cursor_relative_offset, table_horizontal_scroll_delta, table_scroll_data,
table_scrollbar, table_selection_relative_range,
},
model::{
BlockSpacing, CellLayout, LaidOutTable, RenderedSelection, TableBlockConfig,
TableStyle, table_offset_map::TableOffsetMap,
},
},
};
fn test_laid_out_table() -> LaidOutTable {
let source = "h1\th2\nr1\tr2\nx\ty\n";
let table = FormattedTable::from_internal_format(source);
let cell_offset_maps = table_cell_offset_maps(&table, source);
let row_heights = vec![24.0.into_pixels(), 30.0.into_pixels(), 42.0.into_pixels()];
let column_widths = vec![80.0.into_pixels(), 120.0.into_pixels()];
let total_height = row_heights
.iter()
.fold(Pixels::zero(), |acc, row_height| acc + *row_height);
let mut row_y_offsets = Vec::with_capacity(row_heights.len() + 1);
row_y_offsets.push(0.0);
let mut running_y = 0.0;
for row_height in &row_heights {
running_y += row_height.as_f32();
row_y_offsets.push(running_y);
}
let mut col_x_offsets = Vec::with_capacity(column_widths.len() + 1);
col_x_offsets.push(0.0);
let mut running_x = 0.0;
for column_width in &column_widths {
running_x += column_width.as_f32();
col_x_offsets.push(running_x);
}
let offset_map = TableOffsetMap::new(
cell_offset_maps
.iter()
.map(|row| {
row.iter()
.map(|cell| cell.source_length().as_usize())
.collect()
})
.collect(),
);
let content_length = offset_map.total_length();
let config = TableBlockConfig {
width: 200.0.into_pixels(),
spacing: BlockSpacing::default(),
style: TableStyle {
border_color: ColorU::new(10, 11, 12, 255),
header_background: ColorU::new(20, 21, 22, 255),
cell_background: ColorU::new(30, 31, 32, 255),
alternate_row_background: None,
text_color: ColorU::new(40, 41, 42, 255),
header_text_color: ColorU::new(50, 51, 52, 255),
scrollbar_nonactive_thumb_color: ColorU::new(60, 61, 62, 255),
scrollbar_active_thumb_color: ColorU::new(70, 71, 72, 255),
font_family: FamilyId(0),
font_size: 14.0,
cell_padding: 6.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
let cell_layouts = vec![vec![CellLayout::default(); 2]; 3];
let cell_text_frames = vec![vec![Arc::new(TextFrame::mock("")); 2]; 3];
LaidOutTable {
table,
config,
row_heights,
column_widths,
total_height,
offset_map,
content_length,
cell_offset_maps,
row_y_offsets,
col_x_offsets,
cell_text_frames,
cell_layouts,
cell_links: vec![
vec![vec![], vec![]],
vec![vec![], vec![]],
vec![vec![], vec![]],
],
scroll_left: Cell::new(Pixels::zero()),
scrollbar_interaction_state: Default::default(),
horizontal_scroll_allowed: true,
}
}
#[test]
fn model_table_layout_report_matches_model_geometry() {
let laid_out_table = test_laid_out_table();
let report = model_table_layout_report(&laid_out_table);
assert_eq!(report.column_widths, vec![80.0, 120.0]);
assert_eq!(report.column_lefts, vec![0.0, 80.0]);
assert_eq!(report.header_height, 24.0);
assert_eq!(report.row_heights, vec![30.0, 42.0]);
assert_eq!(report.row_tops, vec![0.0, 30.0]);
assert_eq!(report.total_height, 96.0);
}
#[test]
fn row_geometry_from_layout_report_includes_header_row() {
let report = model_table_layout_report(&test_laid_out_table());
let (row_tops, row_heights) = row_geometry_from_layout_report(&report);
assert_eq!(row_tops, vec![0.0, 24.0, 54.0]);
assert_eq!(row_heights, vec![24.0, 30.0, 42.0]);
}
#[test]
fn coordinate_to_offset_returns_cell_start_for_first_cell() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(10.0, 5.0);
assert_eq!(offset, CharOffset::zero());
}
#[test]
fn coordinate_to_offset_targets_second_column() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(90.0, 5.0);
let cell_range = table.offset_map.cell_range(0, 1);
assert!(cell_range.is_some());
let range = cell_range.expect("cell (0,1) should exist");
assert!(
offset >= range.start && offset <= range.end,
"offset {offset:?} should be within cell (0,1) range {:?}..{:?}",
range.start,
range.end,
);
}
#[test]
fn coordinate_to_offset_targets_second_row() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(10.0, 30.0);
let cell_range = table.offset_map.cell_range(1, 0);
assert!(cell_range.is_some());
let range = cell_range.expect("cell (1,0) should exist");
assert!(
offset >= range.start && offset <= range.end,
"offset {offset:?} should be within cell (1,0) range {:?}..{:?}",
range.start,
range.end,
);
}
#[test]
fn cells_in_range_single_cell() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), CharOffset::from(2usize));
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 0);
}
#[test]
fn cells_in_range_across_row() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), CharOffset::from(5usize));
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 0);
assert_eq!(cells[1].row, 0);
assert_eq!(cells[1].col, 1);
}
#[test]
fn cells_in_range_cross_row() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(3usize), CharOffset::from(9usize));
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 1);
assert_eq!(cells[1].row, 1);
assert_eq!(cells[1].col, 0);
}
#[test]
fn cells_in_range_entire_table() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), table.content_length());
assert_eq!(cells.len(), 6);
}
fn single_line_cell_layout(char_count: usize, line_height: f32, line_width: f32) -> CellLayout {
use warpui::text_layout::CaretPosition;
let mut carets = Vec::with_capacity(char_count);
let char_width = if char_count > 0 {
line_width / char_count as f32
} else {
0.0
};
for i in 0..char_count {
carets.push(CaretPosition {
start_offset: i,
last_offset: i,
position_in_line: i as f32 * char_width,
});
}
CellLayout {
line_heights: vec![line_height],
line_y_offsets: vec![0.0],
line_char_ranges: vec![CharOffset::from(0usize)..CharOffset::from(char_count)],
line_widths: vec![line_width],
line_caret_positions: vec![carets],
}
}
#[test]
fn cell_layout_line_at_char_offset_single_line() {
let layout = single_line_cell_layout(5, 21.0, 50.0);
assert_eq!(
layout.line_at_char_offset(CharOffset::from(0usize)),
Some(0)
);
assert_eq!(
layout.line_at_char_offset(CharOffset::from(4usize)),
Some(0)
);
}
#[test]
fn cell_layout_x_for_char_returns_proportional_position() {
let layout = single_line_cell_layout(4, 21.0, 40.0);
let x0 = layout.x_for_char_in_line(0, 0);
let x2 = layout.x_for_char_in_line(0, 2);
assert!(
x0 < x2,
"x at char 0 ({x0}) should be less than x at char 2 ({x2})"
);
}
#[test]
fn cell_layout_x_past_end_returns_line_width() {
let layout = single_line_cell_layout(3, 21.0, 30.0);
let x = layout.x_for_char_in_line(0, 10);
assert_eq!(x, 30.0);
}
#[test]
fn coordinate_to_offset_with_cell_layouts() {
let mut table = test_laid_out_table();
table.cell_layouts = vec![
vec![
single_line_cell_layout(2, 21.0, 40.0),
single_line_cell_layout(2, 21.0, 40.0),
],
vec![
single_line_cell_layout(2, 21.0, 40.0),
single_line_cell_layout(2, 21.0, 40.0),
],
vec![
single_line_cell_layout(1, 21.0, 20.0),
single_line_cell_layout(1, 21.0, 20.0),
],
];
let offset_start = table.coordinate_to_offset(7.0, 1.0);
let cell_0_0 = table
.offset_map
.cell_range(0, 0)
.expect("cell (0,0) should exist");
assert!(
offset_start >= cell_0_0.start && offset_start <= cell_0_0.end,
"offset at (7,1) should be in cell (0,0)",
);
let offset_col1 = table.coordinate_to_offset(100.0, 30.0);
let cell_1_1 = table
.offset_map
.cell_range(1, 1)
.expect("cell (1,1) should exist");
assert!(
offset_col1 >= cell_1_1.start && offset_col1 <= cell_1_1.end,
"offset at (100,30) should be in cell (1,1)",
);
}
#[test]
fn table_selection_relative_range_starts_at_table_start() {
let table = test_laid_out_table();
let range = table_selection_relative_range(
&RenderedSelection::new(CharOffset::from(5usize), CharOffset::from(6usize)),
CharOffset::from(5usize),
&table,
)
.expect("selection should overlap table");
assert_eq!(range, CharOffset::from(0usize)..CharOffset::from(1usize));
}
#[test]
fn table_selection_relative_range_excludes_non_overlapping_selection() {
let table = test_laid_out_table();
let range = table_selection_relative_range(
&RenderedSelection::new(CharOffset::from(1usize), CharOffset::from(4usize)),
CharOffset::from(5usize),
&table,
);
assert!(range.is_none());
}
#[test]
fn table_cursor_relative_offset_starts_at_zero_for_first_table_character() {
let table = test_laid_out_table();
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(CharOffset::from(5usize), CharOffset::from(5usize)),
CharOffset::from(5usize),
&table,
);
assert_eq!(relative_offset, Some(CharOffset::from(0usize)));
}
#[test]
fn table_cursor_relative_offset_excludes_cursor_before_table_start() {
let table = test_laid_out_table();
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(CharOffset::from(4usize), CharOffset::from(4usize)),
CharOffset::from(5usize),
&table,
);
assert_eq!(relative_offset, None);
}
#[test]
fn table_scrollbar_uses_shared_overlay_geometry() {
let table = test_laid_out_table();
let scrollbar = table_scrollbar(
&table,
RectF::new(vec2f(10.0, 20.0), vec2f(90.0, table.height().as_f32())),
90.0.into_pixels(),
)
.expect("wide table should produce a horizontal scrollbar");
assert_eq!(scrollbar.track_bounds.height(), 12.0);
assert_eq!(scrollbar.track_bounds.width(), 90.0);
assert_eq!(scrollbar.thumb_bounds.height(), 8.0);
assert_eq!(scrollbar.thumb_bounds.origin_y(), 106.0);
assert_eq!(scrollbar.thumb_bounds.width(), 40.5);
}
#[test]
fn table_horizontal_scroll_delta_projects_trackpad_motion_without_vertical_jitter() {
assert_eq!(
table_horizontal_scroll_delta(vec2f(12.0, 4.0), true, false),
Some(12.0.into_pixels()),
);
assert_eq!(
table_horizontal_scroll_delta(vec2f(4.0, 12.0), true, false),
None,
);
}
#[test]
fn table_scrollbar_pointer_movement_matches_drag_and_gutter_behavior() {
let table = test_laid_out_table();
let viewport_width = 90.0.into_pixels();
let scroll_data = table_scroll_data(&table, viewport_width);
let scrollbar = table_scrollbar(
&table,
RectF::new(vec2f(10.0, 20.0), vec2f(90.0, table.height().as_f32())),
viewport_width,
)
.expect("wide table should produce a horizontal scrollbar");
assert_eq!(
scroll_delta_for_pointer_movement(20.0.into_pixels(), 38.0.into_pixels(), scroll_data),
(-40.0).into_pixels(),
);
assert_eq!(
scroll_delta_for_pointer_movement(
scrollbar.thumb_center_along(Axis::Horizontal),
75.25.into_pixels(),
scroll_data,
),
(-100.0).into_pixels(),
);
}
#[test]
fn table_scrollbar_drag_state_survives_renderable_recreation() {
let table = test_laid_out_table();
let viewport_width = 90.0.into_pixels();
table.start_scrollbar_drag(
20.0.into_pixels(),
table_scroll_data(&table, viewport_width),
);
let drag_state = table
.scrollbar_drag_state()
.expect("drag state should persist on the table");
let scroll_delta = scroll_delta_for_pointer_movement(
drag_state.start_position_x,
38.0.into_pixels(),
drag_state.scroll_data,
);
assert!(table.set_scroll_left(drag_state.start_scroll_left - scroll_delta, viewport_width));
assert_eq!(table.scroll_left(), 40.0.into_pixels());
assert!(table.end_scrollbar_drag());
}
#[test]
fn table_cursor_relative_offset_excludes_cursor_at_table_end() {
let table = test_laid_out_table();
let table_start = CharOffset::from(5usize);
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(
table_start + table.content_length(),
table_start + table.content_length(),
),
table_start,
&table,
);
assert_eq!(relative_offset, None);
}
@@ -0,0 +1,183 @@
use crate::{
content::text::BufferBlockStyle,
editor::EditorView,
extract_block,
render::model::{BlockItem, RenderState, RichTextStyles, bounds, viewport::ViewportItem},
};
use pathfinder_color::ColorU;
use warpui::elements::ListIndentLevel;
use warpui::{
AppContext, Element, SizeConstraint, WeakViewHandle,
elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, Hoverable, Icon, MouseStateHandle,
Radius, Rect,
},
geometry::vector::vec2f,
platform::Cursor,
};
use super::{
RenderableBlock, RichTextAction,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
// Minimum size constraint for the checkbox point. If the size is smaller than the constraint,
// the svg won't render.
const MIN_CHECK_BOX_SIZE: f32 = 12.;
pub struct RenderableTaskList {
viewport_item: ViewportItem,
task_list_icon: Box<dyn Element>,
icon_size: f32,
placeholder: BlockPlaceholder,
}
impl RenderableTaskList {
pub fn new<V: EditorView>(
complete: bool,
styles: &RichTextStyles,
viewport_item: ViewportItem,
mouse_state: MouseStateHandle,
parent_view: WeakViewHandle<V>,
) -> Self {
let checkmark_icon =
Icon::new(styles.check_box_style.icon_path, ColorU::white()).with_opacity(1.0);
let checkbox_size = styles.base_text.font_size.max(MIN_CHECK_BOX_SIZE);
let (inner, border_width) = if complete {
(checkmark_icon.finish(), 0.)
} else {
(Rect::new().finish(), styles.check_box_style.border_width)
};
let checkbox_length_without_border = checkbox_size - border_width * 2.;
let checkmark_size = checkbox_length_without_border - 3.;
let checkbox = Container::new(
ConstrainedBox::new(
Align::new(
ConstrainedBox::new(inner)
.with_height(checkmark_size)
.with_width(checkmark_size)
.finish(),
)
.finish(),
)
.with_width(checkbox_length_without_border)
.with_height(checkbox_length_without_border)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.with_border(
Border::all(border_width).with_border_fill(styles.check_box_style.border_color),
);
let block_start = viewport_item.block_offset;
let hoverable = Hoverable::new(mouse_state, |state| {
if complete {
checkbox
.with_background(styles.check_box_style.background)
.finish()
} else if state.is_hovered() {
checkbox
.with_background(styles.check_box_style.hover_background)
.finish()
} else {
checkbox.finish()
}
})
.on_click(move |ctx, app, _| {
if let Some(action) = V::Action::task_list_clicked(block_start, &parent_view, app) {
ctx.dispatch_typed_action(action)
}
})
.with_cursor(Cursor::PointingHand)
.finish();
Self {
viewport_item,
task_list_icon: hoverable,
icon_size: checkbox_size,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableTaskList {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.task_list_icon.layout(
SizeConstraint::strict(vec2f(self.icon_size, self.icon_size)),
ctx,
app,
);
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
placeholder::Options {
text: "To-do list",
block_style: match block {
BlockItem::TaskList {
indent_level,
complete,
..
} => BufferBlockStyle::TaskList {
indent_level: *indent_level,
complete: *complete,
},
_ => BufferBlockStyle::TaskList {
indent_level: ListIndentLevel::One,
complete: false,
},
},
}
})
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let task_list = extract_block!(self.viewport_item, content, (block, BlockItem::TaskList{ paragraph: inner, ..}) => block.task_list(inner));
let text_styling = &model.styles().base_text;
let line_origin = ctx.content_to_screen(bounds::visible_origin(
task_list.start_y_offset,
&self.viewport_item.spacing,
));
let content_origin = task_list.content_origin();
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
let checkbox_origin = vec2f(
ctx.content_to_screen(content_origin).x() - space_width - self.icon_size,
// Center the checkbox with respect to the first line of text.
line_origin.y() + (text_styling.line_height().as_f32() - self.icon_size) / 2.,
);
self.task_list_icon.paint(checkbox_origin, ctx.paint, app);
if !self.placeholder.paint(content_origin, model, ctx) {
ctx.draw_paragraph(&task_list, text_styling, model);
}
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.task_list_icon.dispatch_event(event, ctx, app)
}
}
@@ -0,0 +1,101 @@
use warp_core::ui::theme::Fill;
use crate::render::model::{BlockItem, Decoration, RenderState, viewport::ViewportItem};
use super::{RenderableBlock, paint::RenderContext};
pub struct RenderableTemporaryBlock {
viewport_item: ViewportItem,
decoration: Option<Fill>,
text_decoration: Vec<Decoration>,
}
impl RenderableTemporaryBlock {
pub fn new(
viewport_item: ViewportItem,
decoration: Option<Fill>,
text_decoration: Vec<Decoration>,
) -> Self {
Self {
viewport_item,
decoration,
text_decoration,
}
}
}
impl RenderableBlock for RenderableTemporaryBlock {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn overlay_decoration(&self) -> Option<Fill> {
self.decoration
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
// We cannot use `extract_block` macro here since we need to locate the viewport item by content height instead of charoffset
// (temporary block has an offset of zero).
let content = model.content();
let paragraph_block = match content.block_at_height(self.viewport_item.height()) {
Some(block) => match (&block, block.item) {
(
block,
BlockItem::TemporaryBlock {
paragraph_block, ..
},
) => block.temporary_block(paragraph_block),
other => {
log::warn!(
"Unexpected block {other:?} at {}",
self.viewport_item.block_offset
);
return;
}
},
None => return,
};
let start = paragraph_block.start_char_offset;
let paragraph_styles = &model.styles().base_text;
let mut decoration_index = 0;
for paragraph in paragraph_block.paragraphs() {
// We could draw text directly since temporary paragraph should have its own decoration and selection state.
ctx.draw_text(
paragraph.content_origin(),
Default::default(),
paragraph.item.frame(),
paragraph_styles,
);
let paragraph_end = paragraph.end_char_offset();
for (idx, decoration) in self.text_decoration[decoration_index..].iter().enumerate() {
if decoration.start + start >= paragraph_end {
decoration_index += idx;
break;
}
if let Some(highlight) = decoration.background {
paragraph.draw_highlight(
decoration.start + start,
decoration.end + start,
highlight.into(),
ctx,
model.max_line(),
);
}
}
}
}
fn is_temporary(&self) -> bool {
true
}
}
@@ -0,0 +1,44 @@
use crate::{
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{RenderableBlock, paint::RenderContext};
pub struct RenderableTextBlock {
viewport_item: ViewportItem,
}
impl RenderableTextBlock {
pub fn new(viewport_item: ViewportItem) -> Self {
Self { viewport_item }
}
}
impl RenderableBlock for RenderableTextBlock {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let text_block = extract_block!(
self.viewport_item,
content,
(block, BlockItem::TextBlock { paragraph_block }) => block.text_block(paragraph_block)
);
let paragraph_styles = &model.styles().base_text;
for paragraph in text_block.paragraphs() {
ctx.draw_paragraph(&paragraph, paragraph_styles, model);
}
}
}
@@ -0,0 +1,124 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::model::{BlockItem, RenderState, RichTextStyles, bounds, viewport::ViewportItem},
};
use warpui::elements::ListIndentLevel;
use warpui::{
Element, SizeConstraint,
elements::{Border, CornerRadius, Radius, Rect},
geometry::vector::vec2f,
};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
// Minimum size constraint for the bullet point. If the size is smaller than the constraint,
// the svg won't render.
const MIN_BULLET_POINT_SIZE: f32 = 6.;
pub struct RenderableBulletList {
viewport_item: ViewportItem,
bullet_point: Box<dyn Element>,
bullet_size: f32,
placeholder: BlockPlaceholder,
}
impl RenderableBulletList {
pub fn new(
indent_level: ListIndentLevel,
styles: &RichTextStyles,
viewport_item: ViewportItem,
) -> Self {
let bullet_point = match indent_level {
// Solid bullet point.
ListIndentLevel::One => Rect::new()
.with_background_color(styles.base_text.text_color)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
// Hollow bullet point.
ListIndentLevel::Two => Rect::new()
.with_border(Border::all(2.).with_border_color(styles.base_text.text_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
// Solid square.
ListIndentLevel::Three => Rect::new()
.with_background_color(styles.base_text.text_color)
.finish(),
};
Self {
viewport_item,
bullet_point,
bullet_size: (styles.base_text.font_size / 2.).max(MIN_BULLET_POINT_SIZE),
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableBulletList {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.bullet_point.layout(
SizeConstraint::strict(vec2f(self.bullet_size, self.bullet_size)),
ctx,
app,
);
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
let indent_level = match block {
BlockItem::UnorderedList { indent_level, .. } => *indent_level,
_ => ListIndentLevel::One,
};
placeholder::Options {
text: "List",
block_style: BufferBlockStyle::UnorderedList { indent_level },
}
})
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let unordered_list = extract_block!(self.viewport_item, content, (block, BlockItem::UnorderedList{ paragraph: inner, ..}) => block.unordered_list(inner));
let text_styling = &model.styles().base_text;
// The real bound of the unordered list could be slightly lower than the bound in the viewport item
// because we position it in the center of the minimum height bound (if the content height is smaller
// than the minimum height).
let line_origin = ctx.content_to_screen(bounds::visible_origin(
unordered_list.start_y_offset,
&self.viewport_item.spacing,
));
let content_origin = unordered_list.content_origin();
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
// Paint the bullet point in the buffer padding to the left of the text frame.
let bullet_origin = vec2f(
ctx.content_to_screen(content_origin).x() - space_width - self.bullet_size,
// Position the bullet point to the middle of the first line in the text frame.
line_origin.y() + text_styling.line_height().as_f32() / 2. - self.bullet_size / 2.,
);
self.bullet_point.paint(bullet_origin, ctx.paint, app);
if !self.placeholder.paint(content_origin, model, ctx) {
ctx.draw_paragraph(&unordered_list, text_styling, model);
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! Shared text-layout utilities needed throughout the editor implementation.
#[cfg(test)]
use markdown_parser::FormattedTextInline;
use std::ops::Range;
use std::sync::Arc;
use crate::content::text::{BufferBlockStyle, TextStylesWithMetadata};
use warpui::fonts::TextLayoutSystem;
#[cfg(test)]
use warpui::fonts::{Style, Weight};
use warpui::text_layout::{
ClipConfig, LayoutCache, Line, StyleAndFont, TextAlignment, TextBorder, TextStyle,
};
use warpui::units::{IntoPixels, Pixels};
use warpui::{AppContext, LayoutContext};
use warpui::{color::ColorU, text_layout::TextFrame};
use super::model::{BlockSpacing, ParagraphStyles, RenderState, RichTextStyles};
const HYPERLINK_UNDERLINE_COLOR: u32 = 0x7aa6daff;
#[derive(Clone, Debug, Default)]
pub(crate) struct InlineTextLayoutInput {
pub text: String,
pub style_runs: Vec<(Range<usize>, StyleAndFont)>,
}
/// Utility for laying out rich text.
pub struct TextLayout<'a> {
layout_cache: &'a LayoutCache,
font_cache: TextLayoutSystem<'a>,
rich_text_styles: &'a RichTextStyles,
max_width: f32,
container_scrolls_horizontally: bool,
}
impl<'a> TextLayout<'a> {
pub fn new(
layout_cache: &'a LayoutCache,
font_cache: TextLayoutSystem<'a>,
rich_text_styles: &'a RichTextStyles,
max_width: f32,
) -> Self {
Self {
layout_cache,
font_cache,
rich_text_styles,
max_width,
container_scrolls_horizontally: false,
}
}
/// Indicate that the surrounding container (for example, a code editor) already provides
/// horizontal scrolling over its full content area. Blocks whose rendering would otherwise
/// introduce a nested horizontal scroll (like wide Markdown tables) should render at their
/// full intrinsic width instead and rely on the container's scroll.
pub fn with_container_scrolls_horizontally(mut self, flag: bool) -> Self {
self.container_scrolls_horizontally = flag;
self
}
/// Whether the surrounding container already provides horizontal scrolling over its full
/// content area. See [`Self::with_container_scrolls_horizontally`].
pub fn container_scrolls_horizontally(&self) -> bool {
self.container_scrolls_horizontally
}
/// Builds a [`TextLayout`] from the context passed to `Element::layout`.
pub fn from_layout_context(
ctx: &LayoutContext<'a>,
app: &'a AppContext,
model: &'a RenderState,
) -> Self {
Self::new(
ctx.text_layout_cache,
app.font_cache().text_layout_system(),
model.styles(),
model.viewport().width().as_f32(),
)
.with_container_scrolls_horizontally(model.container_scrolls_horizontally())
}
/// Lay out a single frame of text. The caller is responsible for mapping rich text into
/// the paragraph's styling and spacing, as well as the per-character styles.
///
/// See [`Self::style_and_font`] for help constructing the `style_runs`.
pub fn layout_text(
&self,
text: &str,
paragraph_style: &ParagraphStyles,
spacing: &BlockSpacing,
style_runs: &[(Range<usize>, StyleAndFont)],
) -> Arc<TextFrame> {
self.layout_text_with_options(
text,
paragraph_style,
style_runs,
self.content_width(spacing),
Default::default(),
)
}
pub fn layout_text_with_options(
&self,
text: &str,
paragraph_style: &ParagraphStyles,
style_runs: &[(Range<usize>, StyleAndFont)],
max_width: f32,
alignment: TextAlignment,
) -> Arc<TextFrame> {
if text.is_empty() {
return Arc::new(TextFrame::empty(
paragraph_style.font_size,
paragraph_style.line_height_ratio,
));
}
self.layout_cache.layout_text(
text,
paragraph_style.line_style(),
style_runs,
max_width,
f32::MAX,
alignment,
None,
&self.font_cache,
)
}
/// Lays out placeholder text for empty blocks.
pub fn layout_placeholder(
&self,
text: &str,
block_type: &BufferBlockStyle,
spacing: &BlockSpacing,
) -> Arc<Line> {
let paragraph_styles = self.paragraph_styles(block_type);
let style_and_font = self.style_and_font(
&paragraph_styles,
&TextStylesWithMetadata::default().for_placeholder(),
);
let style_runs = &[(0..text.chars().count(), style_and_font)];
self.layout_cache.layout_line(
text,
paragraph_styles.line_style(),
style_runs,
self.content_width(spacing),
ClipConfig::end(),
&self.font_cache,
)
}
/// Returns the maximum width for text content laid out with the given spacing.
fn content_width(&self, spacing: &BlockSpacing) -> f32 {
self.max_width - spacing.x_axis_offset().as_f32()
}
/// The paragraph-level styling to use for blocks of the given kind.
pub fn paragraph_styles(&self, block_type: &BufferBlockStyle) -> ParagraphStyles {
self.rich_text_styles.paragraph_styles(block_type)
}
/// Given the [paragraph-level](ParagraphStyles) and [text-level](TextStyles) styles applicable
/// to a range of text, build the [`StyleAndFont`] configuration for laying out that text.
pub fn style_and_font(
&self,
paragraph_styles: &ParagraphStyles,
text_styles: &TextStylesWithMetadata,
) -> StyleAndFont {
let font_properties = text_styles.apply_properties(paragraph_styles.properties());
let font_family = if text_styles.is_inline_code() {
self.rich_text_styles.inline_code_style.font_family
} else {
paragraph_styles.font_family
};
let mut styling = TextStyle::default();
if text_styles.is_placeholder() {
styling = styling.with_foreground_color(self.rich_text_styles.placeholder_color);
}
if text_styles.is_strikethrough() {
styling = styling.with_show_strikethrough(true);
}
if text_styles.is_underlined() {
styling = styling.with_underline_color(self.rich_text_styles.base_text.text_color);
}
if text_styles.is_inline_code() {
styling = styling
.with_foreground_color(self.rich_text_styles.inline_code_style.font_color)
.with_background_color(self.rich_text_styles.inline_code_style.background)
.with_border(TextBorder {
color: self.rich_text_styles.inline_code_style.background,
radius: 4,
width: 1,
// Use the set 1.2 line height ratio for inline code backgrounds.
line_height_ratio_override: Some(120),
});
}
if let Some(color) = text_styles.color() {
styling = styling.with_syntax_color(color);
}
let style_and_font = StyleAndFont::new(font_family, font_properties, styling);
if text_styles.is_link() {
add_link_to_style_and_font(style_and_font)
} else {
style_and_font
}
}
pub fn rich_text_styles(&self) -> &'a RichTextStyles {
self.rich_text_styles
}
pub fn max_width(&self) -> Pixels {
self.max_width.into_pixels()
}
}
/// The line height for a line of text. In CSS terminology, this is the height of the
/// [line box](https://www.w3.org/TR/css-inline-3/#line-box). In typographic terms,
/// it should correspond to the distance between the [top](https://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font)
/// of one line and the top of the next.
///
/// Unlike [`Line::height`], this height does not depend on the specific text in the line. The
/// `height` field measures the line's actual bounds, so it depends on whether glyphs go below
/// the baseline or above the [cap line](https://www.canva.com/learn/typography-terms/).
pub(crate) fn line_height(line: &Line) -> f32 {
line.font_size * line.line_height_ratio
}
pub(crate) fn add_link_to_style_and_font(mut style: StyleAndFont) -> StyleAndFont {
let hyperlink_color = ColorU::from_u32(HYPERLINK_UNDERLINE_COLOR);
style.style = style
.style
.with_underline_color(hyperlink_color)
.with_foreground_color(hyperlink_color);
style
}
#[cfg(test)]
pub(crate) fn markdown_inline_to_text_and_style_runs(
inline: &FormattedTextInline,
paragraph_style: &ParagraphStyles,
link_color: Option<ColorU>,
inline_code_background: Option<ColorU>,
) -> InlineTextLayoutInput {
let mut text = String::new();
let mut style_runs = Vec::new();
let mut start = 0usize;
for fragment in inline {
if fragment.text.is_empty() {
continue;
}
text.push_str(&fragment.text);
let len = fragment.text.chars().count();
let end = start + len;
let mut properties = paragraph_style.properties();
if let Some(custom_weight) = fragment.styles.weight {
properties = properties.weight(Weight::from_custom_weight(Some(custom_weight)));
}
if fragment.styles.italic {
properties = properties.style(Style::Italic);
}
let mut text_style = TextStyle::new();
if fragment.styles.strikethrough {
text_style = text_style.with_show_strikethrough(true);
}
if fragment.styles.underline {
text_style = text_style.with_underline_color(paragraph_style.text_color);
}
if fragment.styles.inline_code
&& let Some(background) = inline_code_background
{
text_style = text_style.with_background_color(background);
}
if fragment.styles.hyperlink.is_some()
&& let Some(link_color) = link_color
{
text_style = text_style
.with_foreground_color(link_color)
.with_underline_color(link_color);
}
style_runs.push((
start..end,
StyleAndFont::new(paragraph_style.font_family, properties, text_style),
));
start = end;
}
if text.is_empty() {
style_runs.push((
0..0,
StyleAndFont::new(
paragraph_style.font_family,
paragraph_style.properties(),
TextStyle::new(),
),
));
}
InlineTextLayoutInput { text, style_runs }
}
+17
View File
@@ -0,0 +1,17 @@
//! Rich Text Editor rendering layer - model and UI element for rendering
//! marked-up rich text.
pub mod element;
pub mod layout;
pub mod model;
/// The size for icon buttons within the rich-text editor. This is needed for both layout and
/// painting, so it's defined here.
pub const ICON_BUTTON_SIZE: f32 = 24.;
pub const BLOCK_FOOTER_HEIGHT: f32 = 42.;
pub(crate) const TABLE_LINE_HEIGHT_RATIO: f32 = 1.5;
pub(crate) const TABLE_BASELINE_RATIO: f32 = 0.8;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+592
View File
@@ -0,0 +1,592 @@
//! End-to-end editor tests.
use warp_core::features::FeatureFlag;
use warpui::{App, ModelHandle, ReadModel};
use crate::content::{
buffer::{
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin,
InitialBufferState,
},
selection_model::BufferSelectionModel,
text::{BlockType, BufferBlockItem, IndentBehavior, TextStyles},
version::BufferVersion,
};
use string_offset::CharOffset;
use super::model::{
BlockItem, RenderEvent, RenderState,
test_utils::{TEST_STYLES, init_logging},
};
use crate::content::buffer::ShouldAutoscroll;
#[test]
fn test_simple_edit() {
init_logging();
App::test((), |mut app| async move {
let state = TestState::new(&mut app);
state
.edit(
BufferEditAction::Insert {
text: "x",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
&mut app,
)
.await;
// See comments in EditDelta::layout_delta on why this paragraph has two characters even
// though it (a) doesn't include the initial `<text>` marker and (b) doesn't end in an
// explicit newline.
state.assert_rendered(
&app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
"#,
);
});
}
#[test]
fn test_edit_many_lines() {
init_logging();
App::test((), |mut app| async move {
let state = TestState::new(&mut app);
// Reset with several lines of Markdown at once.
state
.markdown(
r#"a
bb
ccc
dddd
eeeee
ffffff
ggggggg
hhhhhhhh
iiiiiiiii
jjjjjjjjjj
kkkkkkkkkkk
llllllllllll
mmmmmmmmmmmmm
nnnnnnnnnnnnnn
ooooooooooooooo
pppppppppppppppp
qqqqqqqqqqqqqqqqq
rrrrrrrrrrrrrrrrrr
sssssssssssssssssss
tttttttttttttttttttt
uuuuuuuuuuuuuuuuuuuuu
vvvvvvvvvvvvvvvvvvvvvv
wwwwwwwwwwwwwwwwwwwwwww
xxxxxxxxxxxxxxxxxxxxxxxx
yyyyyyyyyyyyyyyyyyyyyyyyy
zzzzzzzzzzzzzzzzzzzzzzzzzz"#,
&mut app,
)
.await;
// Assert that paragraphs are laid out in the correct order.
state.assert_rendered(
&app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
-------- 32.00px / 2 characters --------
Paragraph (3 characters, 1 lines, 32.00px tall)
-------- 64.00px / 5 characters --------
Paragraph (4 characters, 1 lines, 32.00px tall)
-------- 96.00px / 9 characters --------
Paragraph (5 characters, 1 lines, 32.00px tall)
-------- 128.00px / 14 characters --------
Paragraph (6 characters, 1 lines, 32.00px tall)
-------- 160.00px / 20 characters --------
Paragraph (7 characters, 1 lines, 32.00px tall)
-------- 192.00px / 27 characters --------
Paragraph (8 characters, 1 lines, 32.00px tall)
-------- 224.00px / 35 characters --------
Paragraph (9 characters, 1 lines, 32.00px tall)
-------- 256.00px / 44 characters --------
Paragraph (10 characters, 1 lines, 32.00px tall)
-------- 288.00px / 54 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 320.00px / 65 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
-------- 352.00px / 77 characters --------
Paragraph (13 characters, 1 lines, 32.00px tall)
-------- 384.00px / 90 characters --------
Paragraph (14 characters, 1 lines, 32.00px tall)
-------- 416.00px / 104 characters --------
Paragraph (15 characters, 1 lines, 32.00px tall)
-------- 448.00px / 119 characters --------
Paragraph (16 characters, 1 lines, 32.00px tall)
-------- 480.00px / 135 characters --------
Paragraph (17 characters, 1 lines, 32.00px tall)
-------- 512.00px / 152 characters --------
Paragraph (18 characters, 1 lines, 32.00px tall)
-------- 544.00px / 170 characters --------
Paragraph (19 characters, 1 lines, 32.00px tall)
-------- 576.00px / 189 characters --------
Paragraph (20 characters, 1 lines, 32.00px tall)
-------- 608.00px / 209 characters --------
Paragraph (21 characters, 1 lines, 32.00px tall)
-------- 640.00px / 230 characters --------
Paragraph (22 characters, 1 lines, 32.00px tall)
-------- 672.00px / 252 characters --------
Paragraph (23 characters, 1 lines, 32.00px tall)
-------- 704.00px / 275 characters --------
Paragraph (24 characters, 1 lines, 32.00px tall)
-------- 736.00px / 299 characters --------
Paragraph (25 characters, 1 lines, 32.00px tall)
-------- 768.00px / 324 characters --------
Paragraph (26 characters, 1 lines, 32.00px tall)
-------- 800.00px / 350 characters --------
Paragraph (27 characters, 1 lines, 32.00px tall)
"#,
);
});
}
#[test]
fn test_enter_before_horizontal_rule() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(11, app); // At the end of "First line".
state
.edit(
BufferEditAction::Enter {
force_newline: false,
style: Default::default(),
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Paragraph (1 characters, 1 lines, 32.00px tall)
-------- 64.00px / 12 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 82.00px / 13 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_enter_after_horizontal_rule() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(13, app); // At the end of "First line".
state
.edit(
BufferEditAction::Enter {
force_newline: false,
style: Default::default(),
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 50.00px / 12 characters --------
Paragraph (1 characters, 1 lines, 32.00px tall)
-------- 82.00px / 13 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_edit_at_horizontal_rule_end() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(12, app); // At the end of "First line".
state
.edit(
BufferEditAction::Insert {
text: "x",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 50.00px / 12 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
-------- 82.00px / 14 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_edit_after_style() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state
.markdown("Some **styled** text\nAnd `more`", app)
.await;
// Set the cursor to just after the bold text.
state.set_cursor(12, app);
// Insert some new text with style inheritance.
state
.edit(
BufferEditAction::Insert {
text: "!",
style: TextStyles::default().bold(),
override_text_style: None,
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (18 characters, 1 lines, 32.00px tall)
-------- 32.00px / 18 characters --------
Paragraph (9 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_undo_at_block_boundary() {
// This is a regression test for CLD-1178.
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state
.markdown("- [x] A\n- [x] B\n- [ ] C\n- [ ] D", app)
.await;
state.assert_buffer(app, "<cl0:true>A<cl0:true>B<cl0:false>C<cl0:false>D<text>");
// Select from the start of item C up through A and B.
state.set_cursor(5, app);
state.select(BufferSelectAction::SetLastHead { offset: 1.into() }, app);
// Press backspace, deleting A and B.
state
.edit(BufferEditAction::Backspace, EditOrigin::UserTyped, app)
.await;
state.assert_buffer(app, "<cl0:true>C<cl0:false>D<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 18.00px / 2 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 36.00px / 4 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
);
// Undo that change and ensure we revert to the original contents.
state
.edit(BufferEditAction::Undo, EditOrigin::UserInitiated, app)
.await;
state.assert_buffer(app, "<cl0:true>A<cl0:true>B<cl0:false>C<cl0:false>D<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 18.00px / 2 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 36.00px / 4 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 54.00px / 6 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 72.00px / 8 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
)
});
}
#[test]
fn test_convert_first_line() {
// This is a full-stack analogue to test_remove_prefix_and_insert_block_item.
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
// This only uses 2 dashes so it's not parsed as Markdown yet.
state.markdown("--\n```\ncode\n```\n", app).await;
state.assert_buffer(app, "<text>--<code:Shell>code<text>");
// Mimic a Markdown shortcut on the first line.
state.set_cursor(3, app);
state
.edit(
BufferEditAction::RemovePrefixAndStyleBlocks(BlockType::Item(
BufferBlockItem::HorizontalRule,
)),
EditOrigin::UserInitiated,
app,
)
.await;
state.assert_buffer(app, "<hr><code:Shell>code<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 18.00px / 1 characters --------
Code Block - Shell (5 characters, 1 lines, 84.00px tall)
-------- 102.00px / 6 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
);
// Undo that change and ensure we revert to the original contents.
state
.edit(BufferEditAction::Undo, EditOrigin::UserInitiated, app)
.await;
state.assert_buffer(app, "<text>--<code:Shell>code<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (3 characters, 1 lines, 32.00px tall)
-------- 32.00px / 3 characters --------
Code Block - Shell (5 characters, 1 lines, 84.00px tall)
-------- 116.00px / 8 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
)
});
}
/// Helper for testing edits end-to-end. This is essentially a stripped-down editor model.
struct TestState {
content: ModelHandle<Buffer>,
selection: ModelHandle<BufferSelectionModel>,
render: ModelHandle<RenderState>,
layout_updates: async_channel::Receiver<()>,
}
impl TestState {
fn new(app: &mut App) -> Self {
let content = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(content.clone()));
let render = app.add_model(|ctx| RenderState::new(TEST_STYLES, false, None, ctx));
let (layout_tx, layout_rx) = async_channel::unbounded();
app.update(|ctx| {
let render2 = render.clone();
ctx.subscribe_to_model(&content, move |_, event, ctx| match event {
BufferEvent::SelectionChanged { .. } => (),
BufferEvent::ContentChanged {
delta,
should_autoscroll,
..
} => render2.update(ctx, |render_state, _| {
render_state.add_pending_edit(delta.clone(), BufferVersion::new());
if matches!(should_autoscroll, ShouldAutoscroll::Yes) {
render_state.request_autoscroll();
}
}),
BufferEvent::AnchorUpdated { .. } | BufferEvent::ContentReplaced { .. } => (),
});
let content2 = content.clone();
ctx.subscribe_to_model(&render, move |render_state, event, ctx| match event {
RenderEvent::NeedsResize => {
let delta = content2.as_ref(ctx).invalidate_layout();
render_state.update(ctx, |render_state, _| {
render_state.add_pending_edit(delta, BufferVersion::new())
});
}
RenderEvent::LayoutUpdated => {
let _ = layout_tx.try_send(());
}
_ => (),
});
});
Self {
content,
selection,
render,
layout_updates: layout_rx,
}
}
/// Move the cursor to an offset.
fn set_cursor(&self, location: impl Into<CharOffset>, app: &mut App) {
self.select(
BufferSelectAction::AddCursorAt {
offset: location.into(),
clear_selections: true,
},
app,
);
}
fn select(&self, action: BufferSelectAction, app: &mut App) {
self.content.update(app, |buffer, ctx| {
buffer.update_selection(
self.selection.clone(),
action,
AutoScrollBehavior::Selection,
ctx,
);
});
}
/// Apply an edit to the buffer and wait for it to be laid out.
async fn edit(&self, action: BufferEditAction<'_>, origin: EditOrigin, app: &mut App) {
self.content.update(app, |buffer, ctx| {
buffer.update_content(action, origin, self.selection.clone(), ctx)
});
self.layout_updates
.recv()
.await
.expect("Layout channel should not be closed");
}
/// Replace the buffer with the given Markdown.
async fn markdown(&self, markdown: &str, app: &mut App) {
let state = InitialBufferState::markdown(markdown);
self.edit(
BufferEditAction::ReplaceWith(state),
EditOrigin::SystemEdit,
app,
)
.await
}
/// Assert that the render state has the expected contents, as produced by describing its
/// `SumTree` of `BlockItem`s.
#[track_caller]
fn assert_rendered(&self, ctx: &impl ReadModel, expected: &str) {
let rendered = self.render.read(ctx, |render_state, _| {
let content = render_state.content();
let described_content = content.describe_content();
described_content.to_string()
});
// TODO: Consider using https://github.com/rust-analyzer/expect-test.
let rendered = rendered.trim();
let expected = expected.trim();
if rendered != expected {
panic!(
"\nExpected:
====
{expected}
====
Actual:
====
{rendered}
===="
);
}
}
/// Assert that the buffer has the expected contents.
#[track_caller]
fn assert_buffer(&self, ctx: &impl ReadModel, expected: &str) {
let buffer = self.content.read(ctx, |buffer, _| buffer.debug());
let buffer = buffer.trim();
let expected = expected.trim();
if buffer != expected {
panic!(
"\nExpected:
====
{expected}
====
Actual:
====
{buffer}
===="
);
}
}
}
#[test]
fn test_markdown_table_render_starts_at_zero_offset() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let state = TestState::new(&mut app);
state
.markdown("| Name | Age |\n| --- | --- |\n| Alice | 30 |\n", &mut app)
.await;
state.render.read(&app, |render_state, _| {
let content = render_state.content();
let block = content
.block_at_offset(CharOffset::zero())
.expect("table block should exist at offset 0");
assert_eq!(block.start_char_offset, CharOffset::zero());
assert!(matches!(block.item, BlockItem::Table(_)));
});
});
}
#[test]
fn test_markdown_table_count_counts_rendered_tables() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let state = TestState::new(&mut app);
state
.markdown("| Name | Age |\n| --- | --- |\n| Alice | 30 |\n", &mut app)
.await;
let count = state
.render
.read(&app, |render_state, _| render_state.markdown_table_count());
assert_eq!(count, 1);
});
}
+91
View File
@@ -0,0 +1,91 @@
//! Consistent bounds definitions for rich-text blocks.
//!
//! The terminology is loosely based on the [alternative CSS box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model#parts_of_a_box).
//!
//! * The **content box** is the rectangle containing a block's content, without any margins or
//! padding.
//! * The **visible box** is the rectangle containing a block's content, padding, and borders -
//! everything that's visually part of the block.
//! * The **reserved box** is the rectangle containing a block's content, padding, borders, and
//! margin - all space reserved for the block.
use warpui::{
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::Pixels,
};
use super::BlockSpacing;
/// The origin of a block's content box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn content_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
vec2f(
spacing.left_offset().as_f32(),
(y_offset + spacing.top_offset()).as_f32(),
)
}
/// The content box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn content_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(content_origin(y_offset, spacing), content_size)
}
/// The origin of a block's visible box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn visible_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
vec2f(
spacing.margin.left(),
y_offset.as_f32() + spacing.margin.top(),
)
}
/// The visible box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn visible_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(
visible_origin(y_offset, spacing),
content_size
+ vec2f(
spacing.padding.left() + spacing.padding.right(),
spacing.padding.top() + spacing.padding.bottom(),
),
)
}
/// The origin of a block's reserved box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn reserved_origin(y_offset: Pixels) -> Vector2F {
vec2f(0., y_offset.as_f32())
}
/// The reserved box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn reserved_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(
reserved_origin(y_offset),
content_size
+ vec2f(
spacing.x_axis_offset().as_f32(),
spacing.y_axis_offset().as_f32(),
),
)
}
+138
View File
@@ -0,0 +1,138 @@
use std::fmt;
use sum_tree::SumTree;
use super::{BlockItem, LayoutSummary, RenderState};
/// Extension trait for types with verbose descriptive formatting.
pub trait Describe {
/// Describe this item into the given formatter.
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result;
/// Describe this item.
fn describe(&self) -> Description<'_, Self> {
Description(self)
}
}
pub struct Description<'a, T: ?Sized>(&'a T);
impl<T: Describe + ?Sized> fmt::Display for Description<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.0.describe_to(f)
}
}
impl Describe for RenderState {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
let viewport_start = self.viewport.scroll_top().as_f32() as f64;
let viewport_end = viewport_start + self.viewport.height().as_f32() as f64;
writeln!(f, "Viewport: {viewport_start:.2}px to {viewport_end:.2}px")?;
writeln!(f, "Selection: {}", self.selections())?;
let mut in_viewport = false;
let content = self.content.borrow();
let mut cursor = content.cursor::<(), LayoutSummary>();
cursor.descend_to_first_item(&content, |_| true);
while let Some(item) = cursor.item() {
let start_summary = cursor.start();
let item_start = start_summary.height;
let item_end = item_start + item.height().as_f32() as f64;
// Is this the end of the viewport?
if item_start > viewport_end && in_viewport {
in_viewport = false;
writeln!(f, "============> VIEWPORT END <============")?;
}
// Is this the start of the viewport?
if item_end >= viewport_start && item_start <= viewport_end && !in_viewport {
in_viewport = true;
writeln!(f, "============> VIEWPORT START <============")?;
}
writeln!(
f,
"-------- {:.2}px / {} characters --------",
start_summary.height, start_summary.content_length
)?;
writeln!(f, " {}", item.describe())?;
cursor.next();
}
if in_viewport {
writeln!(f, "============> VIEWPORT END <============")?;
}
Ok(())
}
}
impl Describe for SumTree<BlockItem> {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut cursor = self.cursor::<(), LayoutSummary>();
cursor.descend_to_first_item(self, |_| true);
while let Some(item) = cursor.item() {
let summary = cursor.start();
writeln!(
f,
"-------- {:.2}px / {} characters --------\n{}",
summary.height,
summary.content_length,
item.describe()
)?;
cursor.next();
}
Ok(())
}
}
impl Describe for BlockItem {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BlockItem::Paragraph(_) => f.write_str("Paragraph")?,
BlockItem::TextBlock { .. } => f.write_str("Text Block")?,
BlockItem::RunnableCodeBlock {
code_block_type, ..
} => write!(f, "Code Block - {code_block_type}",)?,
BlockItem::MermaidDiagram { .. } => f.write_str("Mermaid Diagram")?,
BlockItem::TemporaryBlock { .. } => f.write_str("Temporary Paragraph")?,
BlockItem::TaskList {
indent_level,
complete,
..
} => write!(
f,
"Task List @ {indent_level} [{}]",
if *complete { "X" } else { " " }
)?,
BlockItem::UnorderedList { indent_level, .. } => {
write!(f, "Unordered List @ {indent_level}")?
}
BlockItem::OrderedList { indent_level, .. } => {
write!(f, "Ordered List @ {indent_level}")?
}
BlockItem::Header { header_size, .. } => write!(f, "{header_size:?}")?,
BlockItem::HorizontalRule(_) => f.write_str("Horizontal Rule")?,
BlockItem::Image { alt_text, .. } => write!(f, "Image: {alt_text}")?,
BlockItem::Table(laid_out_table) => write!(
f,
"Table: {}x{}",
laid_out_table.table.rows.len() + 1,
laid_out_table.table.headers.len()
)?,
BlockItem::TrailingNewLine(_) => f.write_str("Trailing Newline")?,
BlockItem::Embedded(_) => f.write_str("Embedded Item")?,
BlockItem::Hidden { .. } => f.write_str("Hidden")?,
}
write!(
f,
" ({} characters, {} lines, {:.2}px tall)",
self.content_length(),
self.lines(),
self.height()
)
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Hit-testing implementation for the rendering model.
use num_traits::SaturatingSub;
use sum_tree::SeekBias;
use warpui::units::{IntoPixels, Pixels};
use string_offset::CharOffset;
use super::{
BlockItem, Height, HitTestBlockType, LayoutSummary, ParagraphBlock, RenderState, bounds,
positioned::{Positioned, PositionedCursor},
};
#[cfg(test)]
#[path = "location_tests.rs"]
mod tests;
/// A location within the editor, as resolved by hit-testing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
/// An entire block.
Block {
/// The starting character offset of the block (inclusive).
start_offset: CharOffset,
/// The ending character offset of the block (exclusive).
end_offset: CharOffset,
/// Type of the block that was hit.
block_type: HitTestBlockType,
},
/// An exact location within the content space.
Text {
/// Offset of the hit character.
char_offset: CharOffset,
/// Whether or not we clamped to this location (for example, if the position
/// was after the end of text on a line, or after the end of all text
/// in the editor).
clamped: bool,
/// Cursor disposition for soft-wrapped text.
wrap_direction: WrapDirection,
/// The starting offset of the block that contains the hit character.
block_start: CharOffset,
link: Option<String>,
},
}
impl Location {
/// The starting [`CharOffset`] of the block that was hit. All hits are within a single block.
pub fn block_start(&self) -> CharOffset {
match self {
Location::Block { start_offset, .. } => *start_offset,
Location::Text { block_start, .. } => *block_start,
}
}
}
/// With soft-wrapping, the end of one line and the start of the next have the
/// same character offset. The `WrapDirection` indicates which one a location is
/// at.
///
/// Suppose we have the line `longword`, soft-wrapped to
/// ```text
/// long
/// word
/// ```
///
/// Visually, after `g` and before `w` are two distinct locations. However, they
/// have the same character offset, 4.
///
/// To represent the first location, we wrap up:
/// ```text
/// long|
/// word
/// ```
///
/// To represent the second, we wrap down:
/// ```text
/// long
/// |word
/// ```
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum WrapDirection {
/// Place the cursor at the end of the previous line.
Up,
/// Place the cursor at the start of the next line.
#[default]
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HitTestOptions {
/// If true, clamp block-level selections to text selections. Currently, this only matters for
/// a hit in the padding area of a code block. Normally, that's considered a hit on the block
/// rather than its text.
pub force_text_selection: bool,
}
impl RenderState {
/// Performs hit-testing on coordinates relative to the content origin
/// (that is, non-viewported). The provided `options` configure how the hit-testing behaves.
pub fn render_coordinates_to_location(
&self,
x: Pixels,
y: Pixels,
options: &HitTestOptions,
) -> Location {
let content = self.content.borrow();
let mut block_cursor = content.cursor::<Height, LayoutSummary>();
block_cursor.seek(&y.into(), SeekBias::Left);
let Some(block) = block_cursor.positioned_item() else {
// If we're at the end of the editor, bias towards placing new text
// on a new line.
let char_offset = self.max_offset();
log::debug!("Clamped to end: {char_offset}");
return Location::Text {
char_offset,
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: char_offset,
link: None,
};
};
block.coordinates_to_location(x, y, options)
}
/// Performs hit-testing on coordinates relative to the viewport origin.
pub fn viewport_coordinates_to_location(
&self,
x: Pixels,
y: Pixels,
options: &HitTestOptions,
) -> Location {
self.render_coordinates_to_location(
(x + self.viewport.scroll_left()).max(Pixels::zero()),
(y + self.viewport.scroll_top()).max(Pixels::zero()),
options,
)
}
}
impl<'a> Positioned<'a, BlockItem> {
/// Resolve coordinates to a location, assuming they're within this block.
/// The coordinates are all relative to the content origin.
fn coordinates_to_location(&self, x: Pixels, y: Pixels, options: &HitTestOptions) -> Location {
match self.item {
BlockItem::Paragraph(paragraph) => self
.paragraph(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::TextBlock { paragraph_block } => {
self.location_in_paragraph_block(x, y, self.text_block(paragraph_block))
}
BlockItem::TaskList { paragraph, .. } => self
.task_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::UnorderedList { paragraph, .. } => self
.unordered_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::OrderedList { paragraph, .. } => self
.ordered_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::RunnableCodeBlock {
paragraph_block, ..
} => {
// To make text selection more ergonomic, any point on a line with text is
// considered part of the block's text area, including padding. Points within the
// padding above or below a code block's text are considered part of the block
// itself (unless `options.force_text_selection` is true), which allows
// clicking a block to select it.
let text_origin = bounds::content_origin(self.start_y_offset, &self.style);
let text_height_range =
text_origin.y()..=text_origin.y() + paragraph_block.height().as_f32();
if options.force_text_selection || text_height_range.contains(&y.as_f32()) {
// Note: we don't unpad `x` here because it's handled by `location_in_paragraph_block`.
self.location_in_paragraph_block(x, y, self.code_block(paragraph_block))
} else {
Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::Code,
}
}
}
BlockItem::MermaidDiagram { .. } => {
let _ = options;
Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::MermaidDiagram,
}
}
BlockItem::Header { paragraph, .. } => self
.header(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::Embedded(_) => Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::Embedding,
},
BlockItem::Table(laid_out_table) => {
let relative_x = (x.as_f32() - self.content_origin().x()).max(0.0);
let relative_y = (y.as_f32() - self.content_origin().y()).max(0.0);
let cell_offset = laid_out_table.coordinate_to_offset(
relative_x + laid_out_table.scroll_left().as_f32(),
relative_y,
);
let char_offset = self.start_char_offset + cell_offset;
let link = laid_out_table.link_at_offset(cell_offset);
Location::Text {
char_offset,
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: self.start_char_offset,
link,
}
}
BlockItem::HorizontalRule { .. }
| BlockItem::Image { .. }
| BlockItem::TrailingNewLine(_)
| BlockItem::TemporaryBlock { .. }
| BlockItem::Hidden { .. } => Location::Text {
char_offset: self.start_char_offset,
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: self.start_char_offset,
link: None,
},
}
}
fn location_in_paragraph_block(
&self,
x: Pixels,
y: Pixels,
paragraph_block: Positioned<'a, ParagraphBlock>,
) -> Location {
for paragraph in paragraph_block.paragraphs() {
if paragraph.end_y_offset() > y {
let mut location = paragraph.coordinate_to_location(self.unpad_x(x), y);
// Adjust the paragraph-relative start offset to be the start of this block.
if let Location::Text { block_start, .. } = &mut location {
*block_start = self.start_char_offset;
}
return location;
}
}
Location::Text {
char_offset: self.end_char_offset().saturating_sub(&CharOffset::from(1)),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: self.start_char_offset,
link: None,
}
}
/// Remove horizontal padding from an x-coordinate in order to hit-test within a padded
/// paragraph.
fn unpad_x(&self, x: Pixels) -> Pixels {
(x - self.content_origin().x().into_pixels()).max(Pixels::zero())
}
}
@@ -0,0 +1,704 @@
use crate::content::text::{FormattedTable, table_cell_offset_maps};
use crate::{
content::text::{BufferBlockStyle, CodeBlockType},
render::model::{
BlockItem, COMMAND_SPACING, CellLayout, ImageBlockConfig, LaidOutTable, Location,
ParagraphBlock, RenderState, TableBlockConfig, TableStyle,
location::{HitTestBlockType, HitTestOptions, WrapDirection},
table_offset_map,
test_utils::{
TEST_STYLES, laid_out_paragraph, laid_out_unordered_lists, layout_paragraphs,
},
},
};
use pathfinder_color::ColorU;
use std::{cell::Cell, sync::Arc};
use string_offset::CharOffset;
use sum_tree::SumTree;
use warpui::assets::asset_cache::AssetSource;
use warpui::fonts::FamilyId;
use warpui::text_layout::{CaretPosition, TextFrame};
use warpui::units::IntoPixels;
fn test_table_layout() -> LaidOutTable {
let source = "aaa\tbbb\nccc\tddd\n";
let table = FormattedTable::from_internal_format(source);
let cell_offset_maps = table_cell_offset_maps(&table, source);
let offset_map = table_offset_map::TableOffsetMap::new(
cell_offset_maps
.iter()
.map(|row| {
row.iter()
.map(|cell| cell.source_length().as_usize())
.collect()
})
.collect(),
);
let content_length = offset_map.total_length();
let cell_layout = CellLayout {
line_heights: vec![20.0],
line_y_offsets: vec![0.0],
line_char_ranges: vec![CharOffset::from(0)..CharOffset::from(3)],
line_widths: vec![30.0],
line_caret_positions: vec![vec![
CaretPosition {
position_in_line: 0.0,
start_offset: 0,
last_offset: 0,
},
CaretPosition {
position_in_line: 10.0,
start_offset: 1,
last_offset: 1,
},
CaretPosition {
position_in_line: 20.0,
start_offset: 2,
last_offset: 2,
},
]],
};
let cell_frame = Arc::new(TextFrame::mock("aaa"));
LaidOutTable {
table,
config: TableBlockConfig {
width: 60.0.into_pixels(),
spacing: Default::default(),
style: TableStyle {
border_color: ColorU::new(0, 0, 0, 255),
header_background: ColorU::new(0, 0, 0, 255),
cell_background: ColorU::new(0, 0, 0, 255),
alternate_row_background: None,
text_color: ColorU::new(0, 0, 0, 255),
header_text_color: ColorU::new(0, 0, 0, 255),
scrollbar_nonactive_thumb_color: ColorU::new(0, 0, 0, 255),
scrollbar_active_thumb_color: ColorU::new(0, 0, 0, 255),
font_family: FamilyId(0),
font_size: 10.0,
cell_padding: 0.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
},
row_heights: vec![20.0.into_pixels(), 20.0.into_pixels()],
column_widths: vec![30.0.into_pixels(), 30.0.into_pixels()],
total_height: 40.0.into_pixels(),
offset_map,
content_length,
cell_offset_maps,
row_y_offsets: vec![0.0, 20.0, 40.0],
col_x_offsets: vec![0.0, 30.0, 60.0],
cell_text_frames: vec![
vec![cell_frame.clone(), cell_frame.clone()],
vec![cell_frame.clone(), cell_frame],
],
cell_layouts: vec![
vec![cell_layout.clone(), cell_layout.clone()],
vec![cell_layout.clone(), cell_layout],
],
cell_links: vec![vec![vec![], vec![]], vec![vec![], vec![]]],
scroll_left: Cell::new(30.0.into_pixels()),
scrollbar_interaction_state: Default::default(),
horizontal_scroll_allowed: true,
}
}
#[test]
fn test_hit_within_line() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"Hello, world!\n",
&TEST_STYLES,
40.,
)));
assert_eq!(
model.render_coordinates_to_location(
12.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 1.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 2.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_table_hit_testing_accounts_for_horizontal_scroll() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 30.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(BlockItem::Table(Box::new(
test_table_layout(),
))));
assert_eq!(
model.render_coordinates_to_location(
0.0.into_pixels(),
0.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_mermaid_block_uses_block_locations_even_with_forced_text_selection() {
let width = 200.;
let mut model = RenderState::new_for_test(
TEST_STYLES.clone(),
width.into_pixels(),
160.0.into_pixels(),
);
let mermaid = BlockItem::MermaidDiagram {
content_length: 14.into(),
asset_source: AssetSource::Bundled {
path: "bundled/svg/test.svg",
},
config: ImageBlockConfig {
width: 120.0.into_pixels(),
height: 40.0.into_pixels(),
spacing: COMMAND_SPACING,
},
};
let mermaid_height = mermaid.height().as_f32();
model.set_content(SumTree::from_item(mermaid));
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
(mermaid_height / 2.0).into_pixels(),
&Default::default()
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
1.0.into_pixels(),
&HitTestOptions {
force_text_selection: true,
}
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
(mermaid_height - 1.0).into_pixels(),
&HitTestOptions {
force_text_selection: true,
}
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
}
#[test]
fn test_hit_within_list() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_unordered_lists(
"Hello, world!\n",
&TEST_STYLES,
40.,
)));
assert_eq!(
model.render_coordinates_to_location(
18.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 0.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
assert_eq!(
model.render_coordinates_to_location(
29.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 1.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_empty_line() {
// This is a regression test for CLD-591.
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
// Height: 0-32, chars: 0-4
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
// Height: 32-64, chars: 4-5
laid_out_paragraph("\n", &TEST_STYLES, 40.0),
// Height: 64-96, chars: 5-9
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
]);
model.set_content(tree);
// A hit on the empty line should clamp to within that line, not the start of the next one.
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
40.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::from(4),
link: None,
}
);
}
#[test]
fn test_hit_on_soft_wrapped_line() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 60.0.into_pixels(), 30.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"Hello, world!\n",
&TEST_STYLES,
40., // This is less than the viewport width to account for the 20px of margin.
)));
// A hit just after the end of a soft-wrapped line should wrap up.
assert_eq!(
model.render_coordinates_to_location(
46.0.into_pixels(),
6.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::zero(),
link: None,
}
);
// A hit at the start of the next line should have the same char offset, but wrap down.
assert_eq!(
model.render_coordinates_to_location(
0.0.into_pixels(),
15.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_after_end() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 50.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"ABCD\n",
&TEST_STYLES,
50.,
)));
// A hit after the end, but on the same line, is like a soft-wrapped hit.
assert_eq!(
model.render_coordinates_to_location(
45.0.into_pixels(),
10.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::zero(),
link: None,
}
);
// A hit on the line after the last clamps to the end of content, but wraps
// to the placeholder next line.
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
33.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None,
}
);
}
#[test]
fn test_hit_before_start() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"ABCDEFGH\n",
&TEST_STYLES,
model.viewport().width().as_f32(),
)));
// Hit before the start of the first soft-wrapped line, which should clamp to the first
// character.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
10.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 0.into(),
// Not considered clamped, because BlockItem::coordinates_to_location pre-clamps when handling
// padding.
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 0.into(),
link: None,
}
);
// Hit before the start of the second soft-wrapped line, which should clamp to the first
// character of that line.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
20.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 0.into(),
link: None
}
);
}
#[test]
fn test_hit_scrolled() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
// Height: 0-32, chars: 0-3
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
// Height: 32-64, chars: 4-7
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
// Height: 64-96, chars: 8-15
laid_out_paragraph("wrapped\n", &TEST_STYLES, 40.0),
// Height: 96-128, chars: 16-20
laid_out_paragraph("last\n", &TEST_STYLES, 40.0),
]);
model.set_content(tree);
// Scroll the viewport directly since we don't have a ModelContext.
model.viewport.scroll((-40.0).into_pixels(), model.height());
// Because of scrolling, this hits the second line.
assert_eq!(
model.viewport_coordinates_to_location(
22.0.into_pixels(),
0.2.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 6.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 4.into(),
link: None,
}
);
// Now, scroll to the last viewport (containing the last paragraph, part of "ped", and the
// trailing newline).
model.viewport.scroll((-47.0).into_pixels(), model.height());
// This line is soft-wrapped to be partially in-viewport.
assert_eq!(
model.viewport_coordinates_to_location(
36.0.into_pixels(),
0.5.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 15.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 8.into(),
link: None,
}
);
// We should still be able to hit-test at the last line - accounting for the scroll position,
// this is the very end of it.
assert_eq!(
model.viewport_coordinates_to_location(
0.5.into_pixels(),
20.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 16.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 16.into(),
link: None,
}
);
// Hits past the last line should resolve to the last character in the buffer. In this case,
// that's a newline, but in a real editor, it would be the last character of the last line,
// with a TrailingNewline marker after it.
assert_eq!(
model.viewport_coordinates_to_location(
2.0.into_pixels(),
42.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 21.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: 21.into(),
link: None,
}
);
}
#[test]
fn test_hit_padding() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 40.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([laid_out_paragraph("line\n", &TEST_STYLES, 40.0)]);
model.set_content(tree);
// Hit in the padding after the paragraph ends. We should return the character that
// matches the x-axis pixel position on the last line.
assert_eq!(
model.viewport_coordinates_to_location(
38.0.into_pixels(),
22.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 3.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_code_block() {
let width = 200.;
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), width.into_pixels(), 20.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
laid_out_paragraph("Text\n", &TEST_STYLES, width),
BlockItem::RunnableCodeBlock {
paragraph_block: ParagraphBlock::new(layout_paragraphs(
"Code 1\nCode 2",
&TEST_STYLES,
&BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
width - COMMAND_SPACING.x_axis_offset().as_f32(),
)),
code_block_type: Default::default(),
},
]);
model.set_content(tree);
// Blocks by height:
// * 0-32: First paragraph
// * 32-56: Margin above code block
// * 56-66: First line of code
// * 66-76: Second line of code
// * 76-92: Margin below code block
// The code block is inset by 16px.
// Hits within the code block should have the right start location.
assert_eq!(
model.render_coordinates_to_location(
30.0.into_pixels(),
70.0.into_pixels(),
&Default::default()
),
Location::Text {
// The hit should be on the "o" on the second line of code.
char_offset: 13.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
// The code block starts at offset 5.
block_start: 5.into(),
link: None,
}
);
// Hits within the code block's margin are treated as block selections.
assert_eq!(
model.render_coordinates_to_location(
10.0.into_pixels(),
50.0.into_pixels(),
&Default::default()
),
Location::Block {
start_offset: 5.into(),
end_offset: 19.into(),
block_type: HitTestBlockType::Code
}
);
// Hits in the horizontal padding are considered part of the text.
assert_eq!(
model.render_coordinates_to_location(
8.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
assert_eq!(
model.render_coordinates_to_location(
90.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 11.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 5.into(),
link: None
}
);
// The above rule holds even for out-of-bounds points.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
assert_eq!(
model.render_coordinates_to_location(
1000.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 11.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 5.into(),
link: None
}
);
// If block selection is disabled (e.g. due to dragging), we still clamp to text.
assert_eq!(
model.render_coordinates_to_location(
27.0.into_pixels(),
50.0.into_pixels(),
&HitTestOptions {
force_text_selection: true
}
),
Location::Text {
char_offset: 6.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
use std::ops::{Add, Sub};
use itertools::Itertools;
use num_traits::SaturatingSub;
use string_offset::CharOffset;
use super::FrameOffset;
#[cfg(test)]
#[path = "offset_map_tests.rs"]
mod tests;
/// Mapping between visible character offsets and interactive content character offsets. Due to
/// placeholder text, not all characters painted on the screen correspond to interactive characters
/// in the content model.
///
/// The mapping model assumes that, within the overall set of characters in a text frame, only
/// certain character runs are interactive. Within those runs, there's a 1:1 mapping between
/// `char`s in the content model and `char`s in the text frame. It translates in two directions:
/// * From a [`CharOffset`] relative to the start of the content model block (a [`super::Paragraph`])
/// to the character index in the [`warpui::text_layout::TextFrame`].
/// * From a [`FrameOffset`] in the `TextFrame` to the closest `CharOffset` in the content model
/// block (for example, clicking within a placeholder should snap the cursor to a regular content
/// character).
///
/// An interactive character is one that the user can select and edit.
#[derive(Debug, Clone)]
pub struct OffsetMap {
/// Sorted list of interactive content runs.
runs: Vec<SelectableTextRun>,
}
/// A run of selectable/interactive content in the [`OffsetMap`]. This includes all user-written
/// characters, but not placeholder text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectableTextRun {
/// The offset of the first content-character in this run.
pub content_start: CharOffset,
/// The offset of the first [`TextFrame`] visible character in this run.
pub frame_start: FrameOffset,
/// The total number of characters in this run. The run length is the same for both content
/// characters and text frame characters. That is, this run represents:
/// * Characters `content_start..content_start+length` within the containing paragraph
/// * Characters `frame_start..frame_start+length` within the containing `TextFrame`
pub length: usize,
}
impl OffsetMap {
/// Creates a new [`OffsetMap`] that translates 1:1 between visible character offsets and
/// interactive content character offsets.
pub fn direct(length: usize) -> Self {
Self::new(vec![SelectableTextRun {
content_start: CharOffset::zero(),
frame_start: FrameOffset::zero(),
length,
}])
}
pub fn new(mut runs: Vec<SelectableTextRun>) -> Self {
runs.sort_unstable_by_key(|r| r.frame_start);
if cfg!(debug_assertions) {
// Content- and frame-offsets should both increase from run to run.
for (a, b) in runs.iter().tuple_windows() {
assert!(a.content_start < b.content_start, "Runs must be ascending");
}
}
Self { runs }
}
/// Translate a visible character to the closest content character.
pub fn to_content(&self, offset: FrameOffset) -> CharOffset {
self.translate(offset)
}
/// Translate a content character to the corresponding visible character.
pub fn to_frame(&self, offset: CharOffset) -> FrameOffset {
// When going from content to frame offsets, the character is almost always going to be
// inside an interactive run. The exception is the placeholder marker character, and so
// it's simpler to reuse the translation algorithm from the other direction rather than
// detect and handle this special case.
self.translate(offset)
}
/// Translate from one kind of offset to the other. If the source offset is within an interactive
/// run, this returns the exact location of the character within that run. Otherwise, it rounds
/// up or down to the nearest interactive run.
fn translate<T: ParagraphOffset, U: ParagraphOffset>(&self, offset: T) -> U {
// Find the runs before and after `offset`, and calculate for each:
// - The absolute distance from it to `offset`
// - The translated offset according to it
let after_idx = self.runs.partition_point(|run| T::start(run) < offset);
let after = self.runs.get(after_idx).map(|run| {
let distance = T::start(run) - offset;
// If the run after `offset` is closer, then snap to the start of that run (e.g. the
// first regular character after a placeholder).
(distance, U::start(run))
});
let before = after_idx
.checked_sub(1)
.and_then(|idx| self.runs.get(idx))
.map(|run| {
// For the before run, we measure the distance from its end to offset. However, the
// offset could be within the run, so we clamp down to 0.
let run_end = T::start(run) + run.length;
let distance = offset.saturating_sub(&run_end);
// If we pick the before run, we essentially rebase it onto the destination start, but then
// clamp to stay within the run.
let translation =
U::start(run) + (offset - T::start(run)).as_usize().min(run.length);
(distance, translation)
});
match (before, after) {
(None, None) => U::zero(), // This should only happen if the frame is empty.
(None, Some((_, translation))) => translation,
(Some((_, translation)), None) => translation,
(
Some((before_distance, before_translation)),
Some((after_distance, after_translation)),
) => {
if before_distance < after_distance {
before_translation
} else {
after_translation
}
}
}
}
}
/// Abstracts over [`FrameOffset`] and [`CharOffset`] so that we can use the same conversion
/// algorithm for both.
trait ParagraphOffset:
Add<usize, Output = Self> + Ord + Sub<Output = Self> + SaturatingSub + Copy + Sized
{
/// Gets the start offset of a [`SelectableTextRun`] for this offset/coordinate system.
fn start(run: &SelectableTextRun) -> Self;
fn zero() -> Self;
fn as_usize(self) -> usize;
}
impl ParagraphOffset for CharOffset {
fn start(run: &SelectableTextRun) -> Self {
run.content_start
}
fn zero() -> Self {
CharOffset::zero()
}
fn as_usize(self) -> usize {
CharOffset::as_usize(self)
}
}
impl ParagraphOffset for FrameOffset {
fn start(run: &SelectableTextRun) -> Self {
run.frame_start
}
fn zero() -> Self {
FrameOffset::zero()
}
fn as_usize(self) -> usize {
FrameOffset::as_usize(self)
}
}
@@ -0,0 +1,287 @@
use super::{OffsetMap, SelectableTextRun};
use crate::render::model::FrameOffset;
use string_offset::CharOffset;
#[test]
fn test_offset_map_basic() {
// Baseline test for a no-placeholder OffsetMap. The content_start is non-zero to mimic
// paragraphs within a code block.
let map = OffsetMap::new(vec![SelectableTextRun {
content_start: CharOffset::from(12),
frame_start: FrameOffset::zero(),
length: 10,
}]);
// The returned offset should be adjusted by the content start.
assert_eq!(map.to_content(FrameOffset::from(4)), 16.into());
// Mapping should clamp to run bounds.
assert_eq!(map.to_content(FrameOffset::from(12)), 22.into());
}
#[test]
fn test_offset_map_placeholders() {
// Set up an offset map for the following structure:
// |placeholder|text|placeholder|text|placeholder...
// Frame: 0 6 14 24 28
// Content: 0 1 9 10 14
let map = OffsetMap::new(vec![
SelectableTextRun {
// Even in the zero-state placeholder case, there's an empty content run just before it.
content_start: CharOffset::zero(),
frame_start: FrameOffset::zero(),
length: 0,
},
SelectableTextRun {
content_start: CharOffset::from(1),
frame_start: FrameOffset::from(6),
length: 8,
},
SelectableTextRun {
content_start: CharOffset::from(10),
frame_start: FrameOffset::from(24),
length: 4,
},
]);
// Depending on what they're closer to, characters at the start of the frame map to either
// the start of the line or the first content run.
assert_eq!(map.to_content(FrameOffset::from(2)), CharOffset::zero());
assert_eq!(map.to_content(FrameOffset::from(4)), CharOffset::from(1));
assert_eq!(map.to_frame(CharOffset::zero()), FrameOffset::zero());
assert_eq!(map.to_frame(CharOffset::from(1)), FrameOffset::from(6));
// Characters within the first text range map within the range.
assert_eq!(map.to_content(FrameOffset::from(7)), CharOffset::from(2));
assert_eq!(map.to_frame(CharOffset::from(2)), FrameOffset::from(7));
// Characters within the second placeholder map to the closer run.
assert_eq!(map.to_content(FrameOffset::from(16)), CharOffset::from(9));
assert_eq!(map.to_content(FrameOffset::from(20)), CharOffset::from(10));
assert_eq!(map.to_frame(CharOffset::from(9)), FrameOffset::from(14));
assert_eq!(map.to_frame(CharOffset::from(10)), FrameOffset::from(24));
// Characters in the last placeholder map to the end of the last text run.
assert_eq!(map.to_content(FrameOffset::from(28)), CharOffset::from(14));
assert_eq!(map.to_content(FrameOffset::from(50)), CharOffset::from(14));
assert_eq!(map.to_frame(CharOffset::from(14)), FrameOffset::from(28));
}
/// Walkthrough test to demonstrate how placeholders are represented in the [`OffsetMap`] and
/// [`TextFrame`].
///
/// This test only runs on macOS because it needs a text-layout implementation for [`EditDelta`]
/// that creates non-empty text frames.
#[test]
#[cfg(target_os = "macos")]
fn test_end_to_end() {
// Group imports here so they don't cause "unused import" warnings on other targets.
use warpui::{
App, color::ColorU, elements::Fill, fonts::Cache as FontCache, text_layout::LayoutCache,
};
use crate::{
content::{
buffer::{Buffer, BufferEditAction, EditOrigin},
selection_model::BufferSelectionModel,
text::IndentBehavior,
},
render::{
layout::TextLayout,
model::{
BlockItem, BrokenLinkStyle, CheckBoxStyle, HorizontalRuleStyle, InlineCodeStyle,
PARAGRAPH_MIN_HEIGHT, ParagraphStyles, RenderLayoutOptions, RichTextStyles,
TableStyle, test_utils::TEST_BASELINE_OFFSET,
},
},
};
App::test((), |mut app| async move {
let mut font_cache = FontCache::new(Box::new(warpui::platform::current::FontDB::new()));
let layout_cache = LayoutCache::new();
let paragraph_styles = ParagraphStyles {
font_family: font_cache
.load_system_font("Arial")
.expect("Arial must exist"),
font_size: 12.,
font_weight: Default::default(),
line_height_ratio: 1.2,
text_color: ColorU::white(),
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: None,
};
let inline_code = InlineCodeStyle {
font_family: font_cache
.load_system_font("Arial")
.expect("Arial must exist"),
background: ColorU::black(),
font_color: ColorU::white(),
};
let checkbox = CheckBoxStyle {
border_color: ColorU::white(),
border_width: 2.,
icon_path: "bundled/svg/check-thick.svg",
background: ColorU::black(),
hover_background: ColorU::black(),
};
let horizontal_rule = HorizontalRuleStyle {
rule_height: 2.,
color: ColorU::black(),
};
let broken_link = BrokenLinkStyle {
icon_path: "bundled/svg/link-broken-02.svg",
icon_color: ColorU::black(),
};
let styles = RichTextStyles {
base_text: paragraph_styles,
code_text: paragraph_styles,
embedding_text: paragraph_styles,
code_background: Default::default(),
embedding_background: Default::default(),
placeholder_color: ColorU::black(),
code_border: Default::default(),
selection_fill: Fill::None,
cursor_fill: Fill::None,
inline_code_style: inline_code,
check_box_style: checkbox,
horizontal_rule_style: horizontal_rule,
broken_link_style: broken_link,
block_spacings: Default::default(),
show_placeholder_text_on_empty_block: false,
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
cursor_width: 1.,
highlight_urls: true,
table_style: TableStyle {
border_color: ColorU::black(),
header_background: ColorU::black(),
cell_background: ColorU::black(),
alternate_row_background: None,
text_color: ColorU::white(),
header_text_color: ColorU::white(),
scrollbar_nonactive_thumb_color: ColorU::white(),
scrollbar_active_thumb_color: ColorU::white(),
font_family: paragraph_styles.font_family,
font_size: 12.,
cell_padding: 8.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
// Start by creating a buffer with a single line of text that includes a placeholder.
let buffer_handle = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection_handle = app.add_model(|_| BufferSelectionModel::new(buffer_handle.clone()));
buffer_handle.update(&mut app, |buffer, ctx| {
buffer.update_content(
BufferEditAction::Insert {
text: "HelloWorld",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserInitiated,
selection_handle.clone(),
ctx,
);
buffer.update_content(
BufferEditAction::InsertPlaceholder {
text: "test",
location: CharOffset::from(6),
},
EditOrigin::SystemEdit,
selection_handle.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>Hello<placeholder_s>test<placeholder_e>World"
);
// The placeholder only counts as 1 character, so there are 11 buffer characters.
assert_eq!(buffer.max_charoffset(), 12.into());
});
// Now, lay out the buffer, which should produce a single `Paragraph` block.
let layout = app.read(|ctx| {
let delta = buffer_handle.as_ref(ctx).invalidate_layout();
let text_layout = TextLayout::new(
&layout_cache,
font_cache.text_layout_system(),
&styles,
1000.,
);
delta.layout_delta(
&text_layout,
None,
RenderLayoutOptions::default(),
None,
ctx,
)
});
let paragraph = match &layout.laid_out_line[..] {
[BlockItem::Paragraph(paragraph)] => paragraph,
other => panic!("Unexpected blocks: {other:?}"),
};
// The `TextFrame` includes each character we paint: "HellotestWorld".
let line = &paragraph.frame.lines()[0];
assert_eq!(
line.runs.iter().map(|run| run.glyphs.len()).sum::<usize>(),
14
);
assert_eq!(line.first_index(), 0); // The "H" glyph.
assert_eq!(line.last_index(), 13); // The "d" glyph.
// Because "test" is a placeholder, it creates a gap in the `OffsetMap`:
// - Characters 0-4 in the buffer map to characters 0-5 in the text frame ("Hello")
// - The character at buffer index 5 is the placeholder ("test"). It's not in the map, but
// is painted by characters 5-8 in the TextFrame
// - Characters 6-10 in the buffer map to characters 9-13 in the text frame ("World").
// Overall, it looks like this:
// Character: H e l l o | t e s t | W o r l d
// Buffer Index: 0 1 2 3 4 | 5 | 6 7 8 9 10
// TextFrame Index: 0 1 2 3 4 | 5 6 7 8 | 9 10 11 12 13
// In the OffsetMap representation, we only store the runs of non-placeholder characters,
// while placeholder characters form un-selectable "holes".
assert_eq!(
paragraph.offsets.runs,
vec![
// The run for "Hello":
SelectableTextRun {
content_start: 0.into(),
frame_start: 0.into(),
length: 5
},
// The run for "World":
SelectableTextRun {
content_start: 6.into(),
frame_start: 9.into(),
length: 5
}
]
);
// To go from a buffer character to a TextFrame character, we find the run that contains
// it - for offset `i`, this is the run where `run.content_start <= i < run.content_start + run.length`.
// Going from a TextFrame character to a buffer character is more complicated, because the
// character might belong to a placeholder. In that case, we find the two adjacent runs and
// pick the closest.
// Some examples:
// The "e" in "Hello":
assert_eq!(paragraph.offsets.to_frame(1.into()), 1.into());
assert_eq!(paragraph.offsets.to_content(1.into()), 1.into());
// The "s" in "test":
// Since it's in a placeholder, we can only use the placeholder's buffer char offset.
assert_eq!(paragraph.offsets.to_frame(5.into()), 5.into());
// When going the other direction, it's closer to World than Hello.
assert_eq!(paragraph.offsets.to_content(7.into()), 6.into());
// The "r" in "World": Note that the offsets don't map 1:1 because we have to account for
// the placeholder gap.
assert_eq!(paragraph.offsets.to_frame(8.into()), 11.into());
assert_eq!(paragraph.offsets.to_content(11.into()), 8.into());
});
}
@@ -0,0 +1,246 @@
//! Utilities for traversing laid-out blocks along with their positioning
//! information.
use std::sync::Arc;
use sum_tree::{Cursor, Dimension};
use warpui::{
geometry::vector::Vector2F,
text_layout::Line,
units::{IntoPixels, Pixels},
};
use crate::render::layout::line_height;
use string_offset::CharOffset;
use super::{
BlockItem, BlockSpacing, HorizontalRuleConfig, ImageBlockConfig, LaidOutEmbeddedItem,
LaidOutTable, LayoutSummary, LineCount, Paragraph, ParagraphBlock, RenderContext, bounds,
};
/// Wrapper to track an item's position, both in the buffer and on the screen.
#[derive(Debug)]
pub struct Positioned<'a, T> {
/// The starting character offset of this item, relative to the start of
/// the buffer.
pub start_char_offset: CharOffset,
/// The starting line number of this item.
pub start_line: LineCount,
/// The starting y-offset of this item, relative to the origin of the laid-out
/// content.
// TODO: There are at least 4 valid origins for pixel coordinates (content start,
// viewport start, paint origin, and block start). If that starts getting
// confusing, we might want to introduce wrappers similar to `DisplayPoint`
// and `SoftWrapPoint` in the input editor or `WithinBlock` and `WithinModel`
// in the terminal.
pub start_y_offset: Pixels,
pub style: BlockSpacing,
pub item: &'a T,
}
pub trait PositionedCursor<'a> {
/// The block at the current cursor position, along with its position.
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>>;
}
impl<'a, S: Dimension<'a, LayoutSummary>> PositionedCursor<'a>
for Cursor<'a, BlockItem, S, LayoutSummary>
{
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>> {
let item = self.item()?;
let summary = self.start();
Some(Positioned {
start_char_offset: summary.content_length,
start_y_offset: (summary.height as f32).into_pixels(),
start_line: summary.lines,
style: item.spacing(),
item,
})
}
}
impl<T> Positioned<'_, T> {
/// The origin of this item's content, relative to the start of the buffer.
pub fn content_origin(&self) -> Vector2F {
bounds::content_origin(self.start_y_offset, &self.style)
}
/// The visible origin of this item, relative to the start of the buffer.
pub fn visible_origin(&self) -> Vector2F {
bounds::visible_origin(self.start_y_offset, &self.style)
}
/// The origin of this item, relative to the start of the buffer, with no padding
/// or margin.
pub fn reserved_origin(&self) -> Vector2F {
bounds::reserved_origin(self.start_y_offset)
}
/// The origin of this item in rendering coordinates.
pub fn render_origin(&self, ctx: &RenderContext) -> Vector2F {
ctx.content_to_screen(self.reserved_origin())
}
}
/// Helpers specific to positioned [`Line`]s.
impl Positioned<'_, Line> {
pub fn end_y_offset(&self) -> Pixels {
self.start_y_offset + line_height(self.item).into_pixels() + self.style.top_offset()
}
}
/// Helpers specific to a positioned [`BlockItem`].
impl<'a> Positioned<'a, BlockItem> {
/// The ending character offset of this item (exclusive).
pub fn end_char_offset(&self) -> CharOffset {
self.start_char_offset + self.item.content_length()
}
/// Check if this block item contains a content offset.
pub fn contains_content(&self, offset: CharOffset) -> bool {
self.start_char_offset <= offset && self.end_char_offset() > offset
}
/// The ending line number of this item (exclusive).
pub fn end_line(&self) -> LineCount {
self.start_line + self.item.lines()
}
/// Position this item's code block.
pub fn code_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::RunnableCodeBlock { .. }),
"Must be a runnable code block"
);
self.position(block)
}
pub fn temporary_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::TemporaryBlock { .. }),
"Must be a temporary block"
);
self.position(block)
}
pub fn embedded(
&self,
embedded_item: &'a Arc<dyn LaidOutEmbeddedItem>,
) -> Positioned<'a, Arc<dyn LaidOutEmbeddedItem>> {
debug_assert!(
matches!(self.item, BlockItem::Embedded(_)),
"Must be an embedded object"
);
self.position(embedded_item)
}
pub fn task_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::TaskList { .. }),
"Must be a task list block"
);
self.position(paragraph)
}
pub fn unordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::UnorderedList { .. }),
"Must be an unordered list block"
);
self.position(paragraph)
}
/// Position the content paragraph for an ordered list item.
pub fn ordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::OrderedList { .. }),
"Must be an ordered list block"
);
self.position(paragraph)
}
pub fn header(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::Header { .. }),
"Must be a header block"
);
self.position_centered(paragraph, paragraph.height())
}
/// Position this item's paragraph.
pub fn paragraph(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::Paragraph(_)),
"Must be a paragraph"
);
// Short paragraphs may have extra padding to meet the minimum paragraph height.
self.position_centered(paragraph, paragraph.height())
}
pub fn text_block(
&self,
paragraph_block: &'a ParagraphBlock,
) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::TextBlock { .. }),
"Must be a text block"
);
self.position(paragraph_block)
}
/// Position the trailing newline cursor.
pub fn trailing_newline(&self, cursor: &'a super::Cursor) -> Positioned<'a, super::Cursor> {
// Match the spacing behavior of single-line paragraphs.
self.position_centered(cursor, cursor.height)
}
pub fn horizontal_rule(
&self,
horizontal_rule: &'a HorizontalRuleConfig,
) -> Positioned<'a, HorizontalRuleConfig> {
self.position_centered(horizontal_rule, horizontal_rule.line_height)
}
pub fn image(&self, image_config: &'a ImageBlockConfig) -> Positioned<'a, ImageBlockConfig> {
self.position_centered(image_config, image_config.height)
}
pub fn table(&self, laid_out_table: &'a LaidOutTable) -> Positioned<'a, LaidOutTable> {
debug_assert!(
matches!(self.item, BlockItem::Table(_)),
"Must be a table block"
);
self.position_centered(laid_out_table, laid_out_table.height())
}
/// Helper to position-wrap an item's contents when pattern-matching.
///
/// ```ignore
/// match positioned.item {
/// BlockItem::Paragraph(paragraph) => positioned.position(paragraph),
/// }
/// ```
fn position<T>(&self, content: &'a T) -> Positioned<'a, T> {
Positioned {
start_char_offset: self.start_char_offset,
start_y_offset: self.start_y_offset,
start_line: self.start_line,
style: self.style,
item: content,
}
}
/// Like `positioned`, but centers the content based on the block's expected content height.
/// Use this for blocks with a minimum height.
fn position_centered<T>(
&self,
content: &'a T,
actual_content_height: Pixels,
) -> Positioned<'a, T> {
let mut positioned = self.position(content);
let gap = self.item.content_height() - actual_content_height;
positioned.start_y_offset += gap / 2.0.into_pixels();
positioned
}
}
@@ -0,0 +1,32 @@
use warpui::EntityId;
/// Utility for consistently creating and referencing saved position IDs for
/// rich text.
#[derive(Debug, Clone)]
pub struct SavedPositions {
/// Entity ID for the [`super::RenderState`] that owns these positions. This
/// disambiguates IDs across multiple rich text editors.
model_id: EntityId,
}
impl SavedPositions {
/// Create a new `SavedPositions` given the parent model ID.
pub(super) fn new(model_id: EntityId) -> Self {
Self { model_id }
}
/// Saved position ID for the cursor location.
pub fn cursor_id(&self) -> String {
format!("warp_editor:cursor_{}", self.model_id)
}
/// The bounding box for the text selection.
pub fn text_selection_id(&self) -> String {
format!("warp_editor:text_selection_{}", self.model_id)
}
/// The first line of the block that the mouse is currently hovered over.
pub fn hovered_block_start(&self) -> String {
format!("warp_editor:hovered_block_start_{}", self.model_id)
}
}
@@ -0,0 +1,424 @@
use markdown_parser::FormattedTextFragment;
use string_offset::CharOffset;
/// Maps between linear CharOffset positions and table cell coordinates.
///
/// The table content is represented as:
/// ```text
/// Header1\tHeader2\tHeader3\n
/// Cell1\tCell2\tCell3\n
/// Cell4\tCell5\tCell6\n
/// ```
///
/// This structure enables:
/// - Finding which cell contains a given CharOffset
/// - Getting the CharOffset range for a specific cell
/// - Determining if an offset is on a separator (tab or newline)
#[derive(Debug, Clone)]
pub struct TableOffsetMap {
cell_ranges: Vec<CellRange>,
row_ranges: Vec<RowRange>,
cell_index_by_row_col: Vec<Vec<usize>>,
total_length: CharOffset,
num_rows: usize,
num_cols: usize,
}
#[derive(Debug, Clone, Copy)]
struct RowRange {
start: CharOffset,
end: CharOffset,
}
/// A range representing a single cell's position in the linear character stream.
#[derive(Debug, Clone, Copy)]
pub struct CellRange {
pub start: CharOffset,
pub end: CharOffset,
pub row: usize,
pub col: usize,
}
/// The location of a character offset within a table cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellAtOffset {
pub row: usize,
pub col: usize,
pub offset_in_cell: CharOffset,
}
/// The character offset range (start, end) of a cell in the linear content stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellOffsetRange {
pub start: CharOffset,
pub end: CharOffset,
}
/// Result of looking up a CharOffset in the table.
#[derive(Debug, Clone, Copy)]
pub enum TablePosition {
/// Offset is within a cell's text content
InCell {
row: usize,
col: usize,
offset_in_cell: CharOffset,
},
/// Offset is on a tab separator between cells
OnTab { row: usize, after_col: usize },
/// Offset is on a newline at the end of a row
OnNewline { row: usize },
}
#[derive(Debug, Clone)]
pub struct TableCellOffsetMap {
fragment_ranges: Vec<TableCellFragmentRange>,
rendered_length: CharOffset,
source_length: CharOffset,
}
#[derive(Debug, Clone, Copy)]
struct TableCellFragmentRange {
rendered_start: CharOffset,
rendered_end: CharOffset,
source_end: CharOffset,
visible_source_start: CharOffset,
visible_source_end: CharOffset,
}
impl TableOffsetMap {
/// Build a new TableOffsetMap from cell text lengths.
///
/// `cell_lengths` is a 2D array where cell_lengths[row][col] is the character
/// count of that cell's text content.
pub fn new(cell_lengths: Vec<Vec<usize>>) -> Self {
let num_rows = cell_lengths.len();
let num_cols = cell_lengths.first().map(|r| r.len()).unwrap_or(0);
let mut cell_ranges = Vec::new();
let mut row_ranges = Vec::with_capacity(num_rows);
let mut cell_index_by_row_col = Vec::with_capacity(num_rows);
let mut current_offset = CharOffset::zero();
for (row_idx, row) in cell_lengths.iter().enumerate() {
let row_start = current_offset;
let mut row_cell_indices = Vec::with_capacity(row.len());
for (col_idx, &cell_len) in row.iter().enumerate() {
let start = current_offset;
let end = start + cell_len;
let cell_idx = cell_ranges.len();
cell_ranges.push(CellRange {
start,
end,
row: row_idx,
col: col_idx,
});
row_cell_indices.push(cell_idx);
current_offset = end;
if col_idx < row.len() - 1 {
current_offset += 1;
}
}
current_offset += 1;
row_ranges.push(RowRange {
start: row_start,
end: current_offset,
});
cell_index_by_row_col.push(row_cell_indices);
}
Self {
cell_ranges,
row_ranges,
cell_index_by_row_col,
total_length: current_offset,
num_rows,
num_cols,
}
}
/// Get the total content length including all cells, tabs, and newlines.
pub fn total_length(&self) -> CharOffset {
self.total_length
}
/// Find what's at the given offset.
pub fn position_at_offset(&self, offset: CharOffset) -> Option<TablePosition> {
if offset >= self.total_length {
return None;
}
let row_idx = self
.row_ranges
.partition_point(|row_range| row_range.end <= offset);
let row_range = self.row_ranges.get(row_idx)?;
if offset < row_range.start {
return None;
}
let row_cells = self.cell_index_by_row_col.get(row_idx)?;
let mut previous_cell: Option<CellRange> = None;
for &cell_idx in row_cells {
let cell = *self.cell_ranges.get(cell_idx)?;
if offset < cell.start {
return previous_cell.map(|cell| self.separator_position(cell));
}
if offset < cell.end {
return Some(TablePosition::InCell {
row: cell.row,
col: cell.col,
offset_in_cell: offset - cell.start,
});
}
if offset == cell.end {
return Some(self.separator_position(cell));
}
previous_cell = Some(cell);
}
Some(TablePosition::OnNewline { row: row_idx })
}
/// Find which cell contains the given offset.
/// If the offset is on a separator, returns the cell before the separator.
pub fn cell_at_offset(&self, offset: CharOffset) -> Option<CellAtOffset> {
match self.position_at_offset(offset)? {
TablePosition::InCell {
row,
col,
offset_in_cell,
} => Some(CellAtOffset {
row,
col,
offset_in_cell,
}),
TablePosition::OnTab { row, after_col } => {
let cell = self.cell_range(row, after_col)?;
Some(CellAtOffset {
row,
col: after_col,
offset_in_cell: cell.end - cell.start,
})
}
TablePosition::OnNewline { row } => {
let last_col = self
.cell_index_by_row_col
.get(row)
.map(|cells| cells.len())
.unwrap_or(0)
.saturating_sub(1);
let cell = self.cell_range(row, last_col)?;
Some(CellAtOffset {
row,
col: last_col,
offset_in_cell: cell.end - cell.start,
})
}
}
}
/// Get the character offset range for a specific cell by (row, col).
pub fn cell_range(&self, row: usize, col: usize) -> Option<CellOffsetRange> {
let cell_idx = *self.cell_index_by_row_col.get(row)?.get(col)?;
let cell = self.cell_ranges.get(cell_idx)?;
Some(CellOffsetRange {
start: cell.start,
end: cell.end,
})
}
/// Check if an offset is on a tab or newline separator.
pub fn is_separator(&self, offset: CharOffset) -> bool {
matches!(
self.position_at_offset(offset),
Some(TablePosition::OnTab { .. } | TablePosition::OnNewline { .. })
)
}
/// Get all cells that intersect with the given offset range.
/// Returns cells in row-major order.
pub fn cells_in_range(&self, start: CharOffset, end: CharOffset) -> Vec<CellRange> {
self.cell_ranges
.iter()
.filter(|cell| cell.end > start && cell.start < end)
.copied()
.collect()
}
/// Get the number of rows in the table.
pub fn num_rows(&self) -> usize {
self.num_rows
}
/// Get the number of columns in the table.
pub fn num_cols(&self) -> usize {
self.num_cols
}
fn separator_position(&self, cell: CellRange) -> TablePosition {
let row_len = self
.cell_index_by_row_col
.get(cell.row)
.map(|cells| cells.len())
.unwrap_or(0);
if cell.col + 1 < row_len {
TablePosition::OnTab {
row: cell.row,
after_col: cell.col,
}
} else {
TablePosition::OnNewline { row: cell.row }
}
}
}
// TODO: When we add editable tables or other complex table operations, consider moving
// cell/row boundaries into the `SumTree` with new `BufferText` marker types so that
// per-cell offsets can be derived by seeking to boundaries instead of re-parsing the
// whole table on every edit. The current embedded-text-plus-cached-parse model is
// sufficient for read-only tables; see PR #24326 discussion for context.
impl TableCellOffsetMap {
/// Build a cell offset map from the raw cell `source` text and the parsed `inline`
/// fragments produced by the Markdown parser.
///
/// This walks `source` character-by-character alongside the rendered text of each fragment,
/// which makes it robust to:
/// - backslash escapes (e.g. `\*foo` consumes two source chars for one rendered char),
/// - changes to Markdown marker syntax (we don't hardcode `**`, `*`, `<u>`, etc. here), and
/// - nested styles where adjacent fragments share outer markers (e.g. `**a *b* c**`), since we
/// attribute each marker to the fragment whose rendered text follows it.
pub fn from_inline_and_source(source: &str, inline: &[FormattedTextFragment]) -> Self {
let source_chars: Vec<char> = source.chars().collect();
let total_source_chars = source_chars.len();
let mut fragment_ranges: Vec<TableCellFragmentRange> = Vec::new();
let mut rendered_offset = CharOffset::zero();
let mut source_idx: usize = 0;
for fragment in inline {
let rendered_chars: Vec<char> = fragment.text.chars().collect();
if rendered_chars.is_empty() {
continue;
}
let first_rendered = rendered_chars[0];
while source_idx < total_source_chars {
let sc = source_chars[source_idx];
if sc == '\\'
&& source_idx + 1 < total_source_chars
&& source_chars[source_idx + 1] == first_rendered
{
source_idx += 1;
break;
}
if sc == first_rendered {
break;
}
source_idx += 1;
}
let visible_source_start = CharOffset::from(source_idx);
for &rendered_char in &rendered_chars {
if source_idx >= total_source_chars {
break;
}
let sc = source_chars[source_idx];
if sc == '\\'
&& source_idx + 1 < total_source_chars
&& source_chars[source_idx + 1] == rendered_char
{
source_idx += 2;
} else {
source_idx += 1;
}
}
let visible_source_end = CharOffset::from(source_idx);
let rendered_start = rendered_offset;
let rendered_end = rendered_start + CharOffset::from(rendered_chars.len());
fragment_ranges.push(TableCellFragmentRange {
rendered_start,
rendered_end,
source_end: visible_source_end,
visible_source_start,
visible_source_end,
});
rendered_offset = rendered_end;
}
let total_source_offset = CharOffset::from(total_source_chars);
let fragment_count = fragment_ranges.len();
for i in 0..fragment_count {
let next_start = if i + 1 < fragment_count {
fragment_ranges[i + 1].visible_source_start
} else {
total_source_offset
};
fragment_ranges[i].source_end = next_start;
}
Self {
fragment_ranges,
rendered_length: rendered_offset,
source_length: total_source_offset,
}
}
pub fn rendered_length(&self) -> CharOffset {
self.rendered_length
}
pub fn source_length(&self) -> CharOffset {
self.source_length
}
pub fn rendered_to_source(&self, rendered_offset: CharOffset) -> CharOffset {
if rendered_offset >= self.rendered_length {
return self
.fragment_ranges
.last()
.map(|fragment| fragment.visible_source_end)
.unwrap_or(self.source_length);
}
for fragment in &self.fragment_ranges {
if rendered_offset < fragment.rendered_end {
return fragment.visible_source_start + (rendered_offset - fragment.rendered_start);
}
}
self.source_length
}
pub fn source_to_rendered(&self, source_offset: CharOffset) -> CharOffset {
if source_offset >= self.source_length {
return self.rendered_length;
}
for fragment in &self.fragment_ranges {
if source_offset < fragment.source_end {
if source_offset <= fragment.visible_source_start {
return fragment.rendered_start;
}
if source_offset >= fragment.visible_source_end {
return fragment.rendered_end;
}
return fragment.rendered_start + (source_offset - fragment.visible_source_start);
}
}
self.rendered_length
}
}
#[cfg(test)]
#[path = "table_offset_map_tests.rs"]
mod tests;
@@ -0,0 +1,261 @@
use super::*;
use markdown_parser::Hyperlink;
use markdown_parser::parse_inline_markdown;
use markdown_parser::weight::CustomWeight;
#[test]
fn test_simple_table() {
let map = TableOffsetMap::new(vec![vec![1, 2], vec![3, 1]]);
assert_eq!(map.total_length(), CharOffset::from(11));
assert_eq!(map.num_rows(), 2);
assert_eq!(map.num_cols(), 2);
assert!(matches!(
map.position_at_offset(CharOffset::from(0)),
Some(TablePosition::InCell { row: 0, col: 0, .. })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(1)),
Some(TablePosition::OnTab {
row: 0,
after_col: 0
})
));
assert!(matches!(
map.position_at_offset(CharOffset::from(2)),
Some(TablePosition::InCell { row: 0, col: 1, .. })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(4)),
Some(TablePosition::OnNewline { row: 0 })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(5)),
Some(TablePosition::InCell { row: 1, col: 0, .. })
));
}
#[test]
fn test_cell_at_offset() {
let map = TableOffsetMap::new(vec![vec![3, 3]]);
assert_eq!(
map.cell_at_offset(CharOffset::from(0)),
Some(CellAtOffset {
row: 0,
col: 0,
offset_in_cell: CharOffset::from(0)
})
);
assert_eq!(
map.cell_at_offset(CharOffset::from(2)),
Some(CellAtOffset {
row: 0,
col: 0,
offset_in_cell: CharOffset::from(2)
})
);
assert_eq!(
map.cell_at_offset(CharOffset::from(4)),
Some(CellAtOffset {
row: 0,
col: 1,
offset_in_cell: CharOffset::from(0)
})
);
}
#[test]
fn test_out_of_bounds_offset() {
let map = TableOffsetMap::new(vec![vec![2, 2]]);
assert!(map.position_at_offset(map.total_length()).is_none());
assert!(map.position_at_offset(CharOffset::from(100)).is_none());
assert!(map.cell_at_offset(map.total_length()).is_none());
}
#[test]
fn test_is_separator() {
let map = TableOffsetMap::new(vec![vec![1, 1], vec![1, 1]]);
assert!(!map.is_separator(CharOffset::from(0)));
assert!(map.is_separator(CharOffset::from(1)));
assert!(map.is_separator(CharOffset::from(3)));
assert!(!map.is_separator(CharOffset::from(4)));
}
#[test]
fn test_empty_cells() {
let map = TableOffsetMap::new(vec![vec![0, 3], vec![2, 0]]);
assert_eq!(map.num_rows(), 2);
assert_eq!(map.num_cols(), 2);
assert!(matches!(
map.position_at_offset(CharOffset::from(0)),
Some(TablePosition::OnTab {
row: 0,
after_col: 0
})
));
assert!(matches!(
map.position_at_offset(CharOffset::from(1)),
Some(TablePosition::InCell { row: 0, col: 1, .. })
));
}
#[test]
fn test_cells_in_range() {
let map = TableOffsetMap::new(vec![vec![2, 2], vec![2, 2]]);
let cells = map.cells_in_range(CharOffset::from(0), map.total_length());
assert_eq!(cells.len(), 4);
let first_row = map.cells_in_range(CharOffset::from(0), CharOffset::from(5));
assert_eq!(first_row.len(), 2);
assert_eq!(first_row[0].row, 0);
assert_eq!(first_row[0].col, 0);
assert_eq!(first_row[1].row, 0);
assert_eq!(first_row[1].col, 1);
}
#[test]
fn test_cell_range() {
let map = TableOffsetMap::new(vec![vec![3, 2]]);
assert_eq!(
map.cell_range(0, 0),
Some(CellOffsetRange {
start: CharOffset::from(0),
end: CharOffset::from(3)
})
);
assert_eq!(
map.cell_range(0, 1),
Some(CellOffsetRange {
start: CharOffset::from(4),
end: CharOffset::from(6)
})
);
assert!(map.cell_range(0, 2).is_none());
assert!(map.cell_range(1, 0).is_none());
}
#[test]
fn test_table_cell_offset_map_handles_bold_and_links() {
let source = "**Bold** [Link](https://warp.dev)";
let inline = parse_inline_markdown(source);
assert!(
inline.iter().any(|fragment| fragment
.styles
.weight
.is_some_and(|weight| matches!(weight, CustomWeight::Bold))),
"parsed inline should have a bold fragment"
);
assert!(
inline
.iter()
.any(|fragment| matches!(&fragment.styles.hyperlink, Some(Hyperlink::Url(url)) if url == "https://warp.dev")),
"parsed inline should have a hyperlink fragment"
);
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(map.rendered_length(), CharOffset::from(9));
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_to_source(CharOffset::from(0)),
CharOffset::from(2)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(4)),
CharOffset::from(8)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(5)),
CharOffset::from(10)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(9)),
CharOffset::from(14)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(0)),
CharOffset::from(0)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(2)),
CharOffset::from(0)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(11)),
CharOffset::from(6)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(14)),
CharOffset::from(9)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(32)),
CharOffset::from(9)
);
}
#[test]
fn test_table_cell_offset_map_handles_backslash_escaped_punctuation() {
let source = "a \\*star\\* b";
let inline = parse_inline_markdown(source);
let rendered_text: String = inline
.iter()
.map(|fragment| fragment.text.as_str())
.collect();
assert_eq!(rendered_text, "a *star* b");
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(
map.rendered_length(),
CharOffset::from(rendered_text.chars().count())
);
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_to_source(CharOffset::from(rendered_text.chars().count())),
CharOffset::from(source.chars().count())
);
}
#[test]
fn test_table_cell_offset_map_handles_nested_styles() {
let source = "**a *b* c**";
let inline = parse_inline_markdown(source);
let rendered_text: String = inline
.iter()
.map(|fragment| fragment.text.as_str())
.collect();
assert_eq!(rendered_text, "a b c");
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_length(),
CharOffset::from(rendered_text.chars().count())
);
for (rendered_idx, rendered_char) in rendered_text.chars().enumerate() {
let source_pos = map.rendered_to_source(CharOffset::from(rendered_idx));
assert_eq!(
source.chars().nth(source_pos.as_usize()),
Some(rendered_char),
"rendered {rendered_idx} ({rendered_char:?}) should map to same char in source",
);
}
}
@@ -0,0 +1,323 @@
//! Test helpers for all render model tests.
use parking_lot::Once;
use std::{mem, sync::Arc};
use vec1::{Vec1, vec1};
use crate::content::text::BufferBlockStyle;
use ordered_float::OrderedFloat;
use warpui::elements::ListIndentLevel;
use warpui::{
color::ColorU,
elements::{Border, Fill},
fonts::{FamilyId, Weight},
geometry::vector::vec2f,
text_layout::{CaretPosition, Glyph, Line, Run, TextFrame},
units::{IntoPixels, Pixels},
};
use super::{
BlockItem, BrokenLinkStyle, CheckBoxStyle, DEFAULT_BLOCK_SPACINGS, HorizontalRuleStyle,
InlineCodeStyle, OffsetMap, PARAGRAPH_MIN_HEIGHT, Paragraph, ParagraphStyles, RichTextStyles,
TEXT_SPACING, TableStyle,
};
pub const TEST_BASELINE_OFFSET: f32 = 0.7;
/// Create a new paragraph that occupies the given space but has no content.
pub fn mock_paragraph(height: f32, width: f32, content_length: usize) -> BlockItem {
let frame = TextFrame::new(
vec1![Line {
width,
trailing_whitespace_width: 0.,
runs: Vec::new(),
// The line's effective height is determined by its font size and line height
// ratio, so set those to produce the expected height,
font_size: height,
line_height_ratio: 1.,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: height * TEST_BASELINE_OFFSET,
descent: height * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: Vec::new(),
chars_with_missing_glyphs: Vec::new(),
}],
width,
Default::default(),
);
BlockItem::paragraph(
Arc::new(frame),
OffsetMap::direct(content_length),
content_length.into(),
TEXT_SPACING,
Some(PARAGRAPH_MIN_HEIGHT),
)
}
/// Create a new paragraph block laid out via [`layout`].
pub fn laid_out_paragraph(
text: &str,
styles: &RichTextStyles,
max_width: impl IntoPixels,
) -> BlockItem {
BlockItem::Paragraph(layout_paragraph(
text,
styles,
&BufferBlockStyle::PlainText,
max_width,
))
}
/// Create a new paragraph block laid out via [`layout`].
pub fn laid_out_unordered_lists(
text: &str,
styles: &RichTextStyles,
max_width: impl IntoPixels,
) -> BlockItem {
BlockItem::UnorderedList {
indent_level: ListIndentLevel::One,
paragraph: layout_paragraph(
text,
styles,
&BufferBlockStyle::UnorderedList {
indent_level: ListIndentLevel::One,
},
max_width,
),
}
}
/// Lays out a single paragraph of text.
pub fn layout_paragraph(
text: &str,
styles: &RichTextStyles,
block_style: &BufferBlockStyle,
max_width: impl IntoPixels,
) -> Paragraph {
let Some(text) = text.strip_suffix('\n') else {
panic!("Laid out paragraph should end with newline");
};
let content_length = text.chars().count() + 1; // Add back 1 for the newline.
Paragraph::new(
Arc::new(layout(text, styles, max_width)),
OffsetMap::direct(content_length),
content_length.into(),
vec![],
styles.block_spacings.from_block_style(block_style),
Some(PARAGRAPH_MIN_HEIGHT),
)
}
/// Lays out each hard-wrapped paragraph in `text`
pub fn layout_paragraphs(
text: &str,
styles: &RichTextStyles,
block_style: &BufferBlockStyle,
max_width: impl IntoPixels + Copy,
) -> Vec1<Paragraph> {
Vec1::try_from_vec(
text.split('\n')
.map(|line| {
let frame = Arc::new(layout(line, styles, max_width));
let content_length = line.chars().count() + 1; // Add back 1 for the newline.
Paragraph::new(
frame,
OffsetMap::direct(content_length),
content_length.into(),
vec![],
styles.block_spacings.from_block_style(block_style),
Some(PARAGRAPH_MIN_HEIGHT),
)
})
.collect::<Vec<_>>(),
)
.expect("Should have at least one paragraph")
}
/// Static default color, since [`ColorU`] constructors aren't `const`.
const WHITE: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
pub const TEST_STYLES: RichTextStyles = RichTextStyles {
base_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: None,
},
code_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: Some(4),
},
code_background: Fill::None,
embedding_background: Fill::None,
embedding_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: Some(4),
},
code_border: Border::new(0.),
placeholder_color: WHITE,
selection_fill: Fill::None,
cursor_fill: Fill::None,
inline_code_style: InlineCodeStyle {
font_family: FamilyId(0),
background: WHITE,
font_color: WHITE,
},
check_box_style: CheckBoxStyle {
border_width: 2.,
border_color: WHITE,
icon_path: "bundled/svg/check-thick.svg",
background: WHITE,
hover_background: WHITE,
},
horizontal_rule_style: HorizontalRuleStyle {
rule_height: 2.,
color: WHITE,
},
broken_link_style: BrokenLinkStyle {
icon_path: "bundled/svg/link-broken-02.svg",
icon_color: WHITE,
},
block_spacings: DEFAULT_BLOCK_SPACINGS,
show_placeholder_text_on_empty_block: false,
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
cursor_width: 1.,
highlight_urls: true,
table_style: TableStyle {
border_color: WHITE,
header_background: WHITE,
cell_background: WHITE,
alternate_row_background: None,
text_color: WHITE,
header_text_color: WHITE,
scrollbar_nonactive_thumb_color: WHITE,
scrollbar_active_thumb_color: WHITE,
font_family: FamilyId(0),
font_size: 10.,
cell_padding: 8.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
/// Minimal text layout for unit tests that require populated text frames.
///
/// This implementation soft-wraps at `max_width`, but without any segmentation
/// rules.
pub fn layout(text: &str, styles: &RichTextStyles, max_width: impl IntoPixels) -> TextFrame {
let max_width = max_width.into_pixels();
// For simplicity, pretend characters are square.
let char_width = styles.base_text.font_size.into_pixels();
let mut lines_acc = vec![];
let mut glyphs_acc = vec![];
let mut carets_acc = vec![];
let mut line_width = Pixels::zero();
for (index, ch) in text.chars().enumerate() {
assert_ne!(ch, '\n', "Hard breaks not supported");
if line_width + char_width > max_width {
lines_acc.push(Line {
width: line_width.as_f32(),
trailing_whitespace_width: 0.,
runs: vec![Run {
font_id: warpui::fonts::FontId(0),
styles: Default::default(),
glyphs: mem::take(&mut glyphs_acc),
width: line_width.as_f32(),
}],
font_size: styles.base_text.font_size,
line_height_ratio: styles.base_text.line_height_ratio,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: mem::take(&mut carets_acc),
chars_with_missing_glyphs: Vec::new(),
});
line_width = Pixels::zero();
}
glyphs_acc.push(Glyph {
id: 0,
position_along_baseline: vec2f(line_width.as_f32(), 0.),
index,
width: char_width.as_f32(),
});
carets_acc.push(CaretPosition {
position_in_line: line_width.as_f32(),
start_offset: index,
last_offset: index,
});
line_width += char_width;
}
// Push any remaining characters (or an empty line, if the text frame was empty).
if !glyphs_acc.is_empty() || lines_acc.is_empty() {
lines_acc.push(Line {
width: line_width.as_f32(),
trailing_whitespace_width: 0.,
runs: vec![Run {
font_id: warpui::fonts::FontId(0),
styles: Default::default(),
glyphs: glyphs_acc,
width: line_width.as_f32(),
}],
font_size: styles.base_text.font_size,
line_height_ratio: styles.base_text.line_height_ratio,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: carets_acc,
chars_with_missing_glyphs: Vec::new(),
});
}
let max_width = lines_acc
.iter()
.map(|line| OrderedFloat(line.width))
.max()
.unwrap_or_default();
match Vec1::try_from_vec(lines_acc) {
Ok(lines) => TextFrame::new(lines, max_width.into_inner(), Default::default()),
Err(_) => TextFrame::empty(
styles.base_text.font_size,
styles.base_text.line_height_ratio,
),
}
}
/// Initialize logging for tests. This should be called at the start of any test that needs logging.
pub fn init_logging() {
// If multiple tests run in the same process, we should still only set up logging once.
static INIT: Once = Once::new();
INIT.call_once(|| {
env_logger::builder()
.parse_filters("warp_editor=trace")
.is_test(true)
.init();
});
}
+444
View File
@@ -0,0 +1,444 @@
use float_cmp::ApproxEq;
use sum_tree::{SeekBias, SumTree};
use warpui::{
SizeConstraint,
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::{IntoPixels, Pixels},
};
use crate::render::element::RenderContext;
use string_offset::CharOffset;
use super::{
AUTO_SCROLL_MARGIN, BlockItem, BlockSpacing, Height, HitTestOptions, LayoutSummary, Location,
RenderState, UNIT_MARGIN, bounds, positioned::PositionedCursor,
};
/// For horizontal autoscrolling, it is very easy to "stuck" on a character if it is aligned exactly on the viewport boundary.
/// To help make scrolling more smooth, add a small margin here to overcome these boundaries.
const HORIZONTAL_SCROLL_MARGIN: f32 = 4.;
#[cfg(test)]
#[path = "viewport_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportState {
/// Width of the viewport. Currently, we soft-wrap text to fit this. However,
/// we'll eventually support horizontal scrolling if the viewport is narrower
/// than some minimum content width.
width: Pixels,
/// Height of the viewport. All scrolling and viewporting is in terms of
/// pixels, not lines, as the line height varies for different content.
height: Pixels,
/// Vertical scrolling offset. This is the distance from the start of the
/// content (height 0) to the first visible content.
scroll_top: Pixels,
/// Horizontal scrolling offset.
scroll_left: Pixels,
}
/// A visible, viewported item. This stores all the information needed to lay out and display a
/// block and any associated UI controls in the current viewport.
///
/// Because the viewport item is needed throughout the `Element` lifecycle, it does not directly
/// reference the rendering model. Instead, it holds offsets that refer back to the model, relying
/// on the UI framework to guarantee that the model does not change without a re-render.
#[derive(Debug)]
pub struct ViewportItem {
/// The y-offset to display this item at, relative to the viewport origin.
/// If this is negative, the item is partially above the viewport.
pub viewport_offset: Pixels,
/// The y-offset of this item, relative to the content origin.
pub content_offset: Pixels,
/// The size of this item's content, in pixels.
pub content_size: Vector2F,
/// Spacing around this item.
pub spacing: BlockSpacing,
/// Offset of the start of the block backing this item.
pub block_offset: CharOffset,
}
/// A snapshot of the scroll position. This may only be used to scroll back to the original
/// position, and cannot be inspected.
#[derive(Clone, Copy, Debug)]
pub struct ScrollPositionSnapshot {
/// The offset of the top left character in the viewport. We use this to represent the scroll
/// position, rather than a line count, to be resilient to soft-wrapping changes. If the
/// viewport is resized, then the content that a given line offset refers to will likely be
/// different.
first_character_offset: CharOffset,
}
impl ScrollPositionSnapshot {
/// Map this snapshot back to a `scroll_top` offset for the current render state.
pub(super) fn to_scroll_top(self, render_state: &RenderState) -> Pixels {
render_state
.character_bounds(self.first_character_offset)
.map_or(Pixels::zero(), |bounds| bounds.min_y().into_pixels())
}
/// Snapshot the render state's current scroll position.
pub(super) fn from_scroll_top(render_state: &RenderState) -> Self {
let first_character_offset = match render_state.viewport_coordinates_to_location(
Pixels::zero(),
Pixels::zero(),
&HitTestOptions {
force_text_selection: true,
},
) {
Location::Text { char_offset, .. } => char_offset,
Location::Block { start_offset, .. } => start_offset,
};
Self {
first_character_offset,
}
}
#[cfg(test)]
pub fn first_character_offset(self) -> CharOffset {
self.first_character_offset
}
}
pub struct ViewportIterator<'a> {
cursor: sum_tree::Cursor<'a, BlockItem, Height, LayoutSummary>,
/// The starting y-offset of content to display.
content_start: Pixels,
/// The ending y-offset of content to display (exclusive). This may be past
/// the end of the document, but it just needs to be an upper bound.
content_end: Pixels,
/// Maximum width the painted object could take in the current viewport.
max_width: Pixels,
}
#[derive(Debug, Clone, Copy)]
pub struct SizeInfo {
/// The size of the viewport, in pixels.
pub viewport_size: Vector2F,
/// Whether or not text must be laid out again to fit the new viewport size.
pub needs_layout: bool,
}
impl ViewportState {
/// Create a new `ViewportState` with the given viewport size, scrolled to
/// the top of the document.
pub fn new(width: Pixels, height: Pixels) -> Self {
Self {
width,
height,
scroll_top: Pixels::zero(),
scroll_left: Pixels::zero(),
}
}
/// Width of the viewport. When rendering, it's assumed that the UI
/// element is this wide.
pub fn width(&self) -> Pixels {
self.width
}
/// Height of the viewport. When rendering, it's assumed that the UI element
/// is this tall.
pub fn height(&self) -> Pixels {
self.height
}
/// The current vertical scroll position of the viewport.
pub fn scroll_top(&self) -> Pixels {
self.scroll_top
}
/// The current horizontal scroll position of the viewport.
pub fn scroll_left(&self) -> Pixels {
self.scroll_left
}
/// Vertically scroll by `delta` pixels. Scrolling is capped at `content_height`,
/// which should be the height of the buffer content.
///
/// Returns whether the view should be re-rendered.
pub(super) fn scroll(&mut self, delta: Pixels, content_height: Pixels) -> bool {
self.scroll_to(self.scroll_top - delta, content_height)
}
pub(super) fn scroll_horizontally(&mut self, delta: Pixels, content_width: Pixels) -> bool {
self.scroll_horizontally_to(self.scroll_left - delta, content_width)
}
/// Scroll to the given `scroll_top`, clamped to the end of the buffer.
///
/// Returns whether or not the view needs to be re-rendered.
pub(super) fn scroll_to(&mut self, scroll_top: Pixels, content_height: Pixels) -> bool {
let scroll_top = self.clamp_scroll_offset(scroll_top, content_height, self.height);
let changed = scroll_top.approx_ne(self.scroll_top, UNIT_MARGIN);
if changed {
self.scroll_top = scroll_top;
}
changed
}
pub(super) fn scroll_horizontally_to(
&mut self,
scroll_left: Pixels,
content_width: Pixels,
) -> bool {
let scroll_left = self.clamp_scroll_offset(scroll_left, content_width, self.width);
let changed = scroll_left.approx_ne(self.scroll_left, UNIT_MARGIN);
if changed {
self.scroll_left = scroll_left;
}
changed
}
/// Set the scroll position to an exact location.
#[cfg(test)]
pub(super) fn set_scroll_top(&mut self, scroll_top: Pixels) {
self.scroll_top = scroll_top;
}
/// Notifies the viewport model that the content height has changed, which
/// affects the range of valid scroll positions.
///
/// Returns whether the view should be re-rendered.
pub(super) fn update_content_height(&mut self, content_height: Pixels) -> bool {
// A scroll of 0 will reapply the clamping logic to ensure the scroll
// position is still in bounds.
self.scroll(Pixels::zero(), content_height)
}
pub(super) fn update_content_width(&mut self, content_width: Pixels) -> bool {
// A scroll of 0 will reapply the clamping logic to ensure the scroll
// position is still in bounds.
self.scroll_horizontally(Pixels::zero(), content_width)
}
pub(super) fn autoscroll(
&mut self,
item_start: Vector2F,
item_end: Vector2F,
content_height: Pixels,
content_width: Pixels,
should_autoscroll_horizontally: bool,
) -> bool {
let mut changed = false;
if should_autoscroll_horizontally {
if (item_start.x() - HORIZONTAL_SCROLL_MARGIN).into_pixels() < self.scroll_left {
changed = self.scroll_horizontally(
self.scroll_left - item_start.x().into_pixels()
+ AUTO_SCROLL_MARGIN.into_pixels(),
content_width,
) || changed;
} else if (item_end.x() + HORIZONTAL_SCROLL_MARGIN).into_pixels()
> self.scroll_left + self.width
{
changed = self.scroll_horizontally(
self.scroll_left - item_end.x().into_pixels() + self.width
- AUTO_SCROLL_MARGIN.into_pixels(),
content_width,
) || changed;
}
}
if item_start.y().into_pixels() < self.scroll_top {
// The position we want to scroll to is `item_start - AUTO_SCROLL_MARGIN.into_pixels()`.
changed = self.scroll(
self.scroll_top - item_start.y().into_pixels() + AUTO_SCROLL_MARGIN.into_pixels(),
content_height,
) || changed;
} else if item_end.y().into_pixels() > self.scroll_top + self.height {
// The position we want to scroll to is `item_end - self.height + AUTO_SCROLL_MARGIN.into_pixels()`.
changed = self.scroll(
self.scroll_top - item_end.y().into_pixels() + self.height
- AUTO_SCROLL_MARGIN.into_pixels(),
content_height,
) || changed;
}
changed
}
/// Clamps a scroll position to a valid value. The scroll top must be positive,
/// and is at most the content height minus the viewport height. The viewport
/// is scrolled all the way to the top if the scroll position is 0 and
/// all the way to the bottom if the last viewport's worth of content is
/// visible.
fn clamp_scroll_offset(
&self,
scroll_top: Pixels,
content_height: Pixels,
viewport_height: Pixels,
) -> Pixels {
scroll_top
.min(content_height - viewport_height)
.max(Pixels::zero())
}
/// Calculates the viewport size given layout constraints.
///
/// Because we do not have mutable model access when laying out UI elements,
/// size changes are handled in two steps:
/// 1. [`crate::render::element::RichTextElement`] calls `viewport_size` as
/// part of its `layout` implementation.
/// 2. `RichTextElement` then updates the model with the size it computed
/// in `after_layout`. Since `after_layout` runs before painting and
/// event handling, the model still has enough information to viewport,
/// hit-test, and scroll.
pub(in crate::render) fn viewport_size(
&self,
constraint: SizeConstraint,
size_buffer: Vector2F,
max_width: Option<Pixels>,
) -> SizeInfo {
// TODO(ben): We should have a minimum soft-wrap width. If the constraint's
// maximum size is below this, we start horizontal scrolling rather
// than trying to soft-wrap further.
let mut max_constraint = constraint.max;
if let Some(max_width) = max_width {
max_constraint.set_x(constraint.max.x().min(max_width.as_f32()));
}
let content_constraint = SizeConstraint::new(
(constraint.min - size_buffer).max(Vector2F::zero()),
(max_constraint - size_buffer).max(Vector2F::zero()),
);
let width = content_constraint.max.x();
let height = content_constraint.max.y();
let needs_layout = width.approx_ne(self.width.as_f32(), UNIT_MARGIN);
SizeInfo {
viewport_size: vec2f(width, height),
needs_layout,
}
}
/// Save the viewport size that was calculated by a call to [`viewport_size`]
/// during layout. This should only be called by [`crate::render::element::RichTextElement`],
/// otherwise there's no guarantee that content is soft-wrapped to the correct bounds.
///
/// This may also adjust the scroll position, if it's not valid in the new viewport size.
pub(super) fn set_size(
&mut self,
size: Vector2F,
content_width: Pixels,
content_height: Pixels,
) {
self.width = size.x().into_pixels();
self.height = size.y().into_pixels();
// If set_size is called, the view is already being re-rendered, so we can ignore the
// return value of update_content_height.
self.update_content_height(content_height);
self.update_content_width(content_width);
}
}
impl<'a> ViewportIterator<'a> {
/// Begin an iterator over the current viewport.
pub(super) fn new(
content: &'a SumTree<BlockItem>,
scroll_top: Pixels,
viewport_height: Pixels,
viewport_width: Pixels,
) -> Self {
let mut cursor = content.cursor();
cursor.seek_clamped(&scroll_top.into(), SeekBias::Left);
Self {
cursor,
content_start: scroll_top,
content_end: scroll_top + viewport_height,
max_width: viewport_width,
}
}
}
impl<'a> Iterator for ViewportIterator<'a> {
type Item = (ViewportItem, &'a BlockItem);
fn next(&mut self) -> Option<Self::Item> {
let item = self.cursor.positioned_item()?;
// Stop rendering once the current item is completely outside the viewport.
if item.start_y_offset > self.content_end {
return None;
}
self.cursor.next();
let spacing = item.item.spacing();
let content_width = self.max_width - spacing.x_axis_offset();
let viewport_item = ViewportItem {
viewport_offset: item.start_y_offset - self.content_start,
content_offset: item.start_y_offset,
content_size: vec2f(content_width.as_f32(), item.item.content_height().as_f32()),
spacing,
block_offset: item.start_char_offset,
};
Some((viewport_item, item.item))
}
}
impl ViewportItem {
/// The block backing this viewport item.
pub fn block_offset(&self) -> CharOffset {
self.block_offset
}
pub fn height(&self) -> f64 {
// We sometimes encounter floating point errors when since we are seeking exactly on the edge of
// a block item. Add a small buffer here so we could consistently seek to the right element.
self.content_offset.as_f32() as f64 + 0.1
}
/// The content bounds of this item (see [`bounds::content_box`]).
pub fn content_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::content_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
/// The visible bounds of this item (see [`bounds::visible_box`]).
pub fn visible_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::visible_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
/// The reserved bounds of this item (see [`bounds::reserved_box`]).
pub fn reserved_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::reserved_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
}
#[macro_export]
macro_rules! extract_block {
($viewport_item:expr, $content:expr, $match:pat => $value:expr) => {{
let offset = $viewport_item.block_offset();
match $content.block_at_offset(offset) {
Some(block) => match (&block, block.item) {
$match => $value,
other => {
log::trace!("Unexpected block {other:?} at {}", offset);
return;
}
},
None => return,
}
}};
}
@@ -0,0 +1,189 @@
use itertools::Itertools;
use sum_tree::SumTree;
use warpui::{
SizeConstraint,
geometry::vector::vec2f,
units::{IntoPixels, Pixels},
};
use crate::render::model::{
RenderState,
test_utils::{TEST_STYLES, mock_paragraph},
};
use super::ViewportState;
#[test]
fn test_viewport_offsets() {
let mut render_state =
RenderState::new_for_test(TEST_STYLES, 20.0.into_pixels(), 180.0.into_pixels());
let mut content = SumTree::new();
content.push(mock_paragraph(10., 100., 1));
content.push(mock_paragraph(60., 100., 1));
content.push(mock_paragraph(100., 100., 2));
content.push(mock_paragraph(30., 100., 3));
content.push(mock_paragraph(80., 100., 1));
render_state.set_content(content);
// Double-check the heights with margins+padding, as later tests rely on them.
let heights = render_state
.content
.borrow()
.items()
.iter()
.map(|item| item.height().as_f32())
.collect_vec();
assert_eq!(heights, vec![32., 68., 108., 38., 88., 32.]);
let content = render_state.content();
let offsets = content
.viewport_items(
180.0.into_pixels(),
render_state.viewport().width(),
40.0.into_pixels(),
)
.map(|(item, _)| (item.viewport_offset.as_f32(), item.block_offset.as_usize()))
.collect_vec();
// Each item should be painted starting at the sum of all previous heights,
// offset by scroll_top
assert_eq!(
offsets,
vec![
// The first item is fully above the viewport.
// The second item is slightly above the viewport.
(-8., 1),
// The third is fully within the viewport
(60., 2),
// The fourth is slightly past the viewport, and cut off.
(168., 4)
] // The fifth item is fully after the viewport.
);
}
#[test]
fn test_viewport_item_to_block() {
let mut render_state =
RenderState::new_for_test(TEST_STYLES, 100.0.into_pixels(), 160.0.into_pixels());
let mut content = SumTree::new();
content.push(mock_paragraph(80., 100., 10));
content.push(mock_paragraph(20., 100., 3));
render_state.set_content(content);
let viewport_items = {
let content = render_state.content();
content
.viewport_items(
160.0.into_pixels(),
render_state.viewport().width(),
0.0.into_pixels(),
)
.map(|(item, _)| item)
.collect_vec()
};
let content = render_state.content();
let block0 = content
.block_at_offset(viewport_items[0].block_offset())
.expect("Block should exist");
assert_eq!(block0.start_char_offset, 0.into());
let block1 = content
.block_at_offset(viewport_items[1].block_offset())
.expect("Block should exist");
assert_eq!(block1.start_char_offset, 10.into());
drop(content);
// Now, invalidate the items by pushing new content. They should detect this and fail.
let mut new_tree = SumTree::new();
new_tree.push(mock_paragraph(40., 100., 4));
new_tree.push_tree(render_state.content.borrow().clone());
render_state.set_content(new_tree);
let content = render_state.content();
// There's still _a_ block at char offset 0, which is what's returned here. This is fine, since
// the check in ViewportItem::block is a fallback in case of programmer error.
assert!(
content
.block_at_offset(viewport_items[0].block_offset())
.is_some()
);
// However, the next item no longer has a backing block.
assert!(
content
.block_at_offset(viewport_items[1].block_offset())
.is_none()
);
}
#[test]
fn test_scroll_bounds() {
let content_height = 32.0.into_pixels();
let mut state = ViewportState::new(8.0.into_pixels(), 16.0.into_pixels());
// Scroll down within the document.
assert!(state.scroll((-6.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 6.);
// Now, scroll up, but past the beginning of the document. The scroll_top
// should clamp at 0.
assert!(state.scroll(100.0.into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 0.);
// Now, scroll back down.
assert!(state.scroll((-12.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 12.);
// We can keep scrolling, but it's clamped to the last viewport of the document.
assert!(state.scroll((-100.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 16.);
// If we try to scroll more, it has no effect.
assert!(!state.scroll((-10.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 16.);
}
#[test]
fn test_viewport_width_change() {
let state = ViewportState::new(100.0.into_pixels(), 500.0.into_pixels());
// Shrink the viewport by 20 pixels horizontally. It should use the maximum space available,
// and need layout because of the size change.
let size_info = state.viewport_size(
SizeConstraint::new(vec2f(20., 100.), vec2f(80., 500.)),
vec2f(0., 0.),
None,
);
assert!(size_info.needs_layout);
assert_eq!(size_info.viewport_size, vec2f(80., 500.));
}
#[test]
fn test_viewport_height_change() {
let content_height = 300.0.into_pixels();
let content_width = 100.0.into_pixels();
let mut state = ViewportState::new(100.0.into_pixels(), 200.0.into_pixels());
// Scroll the viewport.
state.scroll(-(50.0.into_pixels()), content_height);
assert_eq!(state.scroll_top, 50.0.into_pixels());
// Resize the viewport such that it no longer has a scrollbar.
state.set_size(vec2f(100., 400.), content_width, content_height);
assert_eq!(state.scroll_top, Pixels::zero());
// Resize the viewport to need scrolling again. This won't autoscroll, however.
state.set_size(vec2f(100., 100.), content_width, content_height);
assert_eq!(state.scroll_top, Pixels::zero());
// Scroll, and then shrink the viewport further. This should preserve the scroll position.
state.scroll(-(50.0.into_pixels()), content_height);
state.set_size(vec2f(100., 80.), content_width, content_height);
assert_eq!(state.scroll_top, 50.0.into_pixels());
// The scroll_top cannot be past the last viewport of content. If the viewport extends such
// that the scroll position is invalid, but a scrollbar is still needed, we'll scroll up slightly.
state.set_size(vec2f(100., 260.), content_width, content_height);
assert_eq!(state.scroll_top, 40.0.into_pixels());
}
+408
View File
@@ -0,0 +1,408 @@
use std::ops::Range;
use itertools::Itertools;
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
use warp_core::ui::theme::Fill;
use warpui::{Entity, ModelContext, ModelHandle, r#async::SpawnedFutureHandle};
use crate::{
content::{
anchor::Anchor,
buffer::{Buffer, BufferEvent},
find::{Query, SearchConfig, SearchResults},
selection_model::BufferSelectionModel,
},
render::model::Decoration,
};
use string_offset::CharOffset;
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
pub struct Searcher {
query_text: String,
case_sensitive: bool,
regex: bool,
buffer: ModelHandle<Buffer>,
selection_model: ModelHandle<BufferSelectionModel>,
results: Option<SearchResults>,
selected_result: Option<SelectedResult>,
search_handle: Option<SpawnedFutureHandle>,
auto_select_on_search: bool,
}
/// A selected search result.
#[derive(Debug, Clone)]
pub struct SelectedResult {
/// The index of the selected result in the overall list of matches.
current_index: usize,
/// Handle to the start of the selected result, used to find it again when rerunning a search.
start: Anchor,
}
impl SelectedResult {
pub fn current_index(&self) -> usize {
self.current_index
}
}
pub trait RestorableSearchResults {
fn valid_matches(&self) -> impl Iterator<Item = (usize, CharOffset)>;
}
#[derive(Debug, Clone, Copy)]
pub enum SearchEvent {
/// Search results have updated.
Updated,
/// The search query is invalid (i.e. a mistyped regex).
InvalidQuery,
/// The selected search result changed.
SelectedResultChanged,
}
lazy_static! {
pub static ref MATCH_FILL: Fill = Fill::Solid(ColorU::new(255, 254, 61, 180));
pub static ref SELECTED_MATCH_FILL: Fill = Fill::Solid(ColorU::new(238, 146, 59, 180));
}
impl Searcher {
pub fn new(
buffer: ModelHandle<Buffer>,
selection_model: ModelHandle<BufferSelectionModel>,
ctx: &mut ModelContext<Self>,
) -> Self {
ctx.subscribe_to_model(&buffer, Self::handle_buffer_event);
Self {
query_text: String::new(),
case_sensitive: false,
regex: false,
buffer,
selection_model,
results: None,
selected_result: None,
search_handle: None,
auto_select_on_search: false,
}
}
/// Returns the index of the closest match at or after the cursor position.
/// If there are no matches after the cursor, wraps around to the first match.
pub fn next_match_index_from_cursor(&self, ctx: &ModelContext<Self>) -> Option<usize> {
let results = self.results.as_ref()?;
if results.matches.is_empty() {
return None;
}
let cursor_position = self.selection_model.as_ref(ctx).first_selection_head();
let final_cursor_position = self.buffer.as_ref(ctx).max_charoffset();
results
.matches
.iter()
.enumerate()
.min_by_key(|(_, m)| {
if m.start <= cursor_position {
// Wrap around: add final position to make it come after matches >= cursor_position
m.start + final_cursor_position
} else {
// Normal distance for matches at or after cursor
m.start - cursor_position
}
})
.map(|(index, _)| index)
}
/// Set the query text.
pub fn set_query(&mut self, query: impl Into<String>, ctx: &mut ModelContext<Self>) {
self.query_text = query.into();
self.selected_result = None;
self.run_search(ctx);
}
/// Whether or not there's a non-empty search query.
pub fn has_query(&self) -> bool {
!self.query_text.is_empty()
}
/// Set regex mode.
pub fn set_regex(&mut self, regex: bool, ctx: &mut ModelContext<Self>) {
self.regex = regex;
self.selected_result = None;
self.run_search(ctx);
}
/// Whether or not regex mode is on.
pub fn is_regex(&self) -> bool {
self.regex
}
/// Set case sensitivity.
pub fn set_case_sensitive(&mut self, case_sensitive: bool, ctx: &mut ModelContext<Self>) {
self.case_sensitive = case_sensitive;
self.selected_result = None;
self.run_search(ctx);
}
/// Whether or not the search is case-sensitive.
pub fn is_case_sensitive(&self) -> bool {
self.case_sensitive
}
pub fn set_auto_select(&mut self, should_auto_select: bool) {
self.auto_select_on_search = should_auto_select;
}
/// The count of search results.
pub fn match_count(&self) -> usize {
self.results
.as_ref()
.map_or(0, |results| results.matches.len())
}
/// The index of the currently-selected match within the overall search results.
pub fn selected_match(&self) -> Option<usize> {
self.selected_result
.as_ref()
.map(|result| result.current_index)
}
/// The text range of the currently-selected match.
pub fn selected_match_range(&self) -> Option<Range<CharOffset>> {
let match_index = self.selected_result.as_ref()?.current_index;
let match_result = self.results.as_ref()?.matches.get(match_index)?;
Some(match_result.start..match_result.end)
}
pub fn select_next_from_cursor(&mut self, ctx: &mut ModelContext<Self>) {
match &self.results {
Some(results) if !results.matches.is_empty() => {
if let Some(next_match_index) = self.next_match_index_from_cursor(ctx) {
self.select_internal(next_match_index, ctx);
}
}
_ => self.clear_selected_result(ctx),
}
}
pub fn select_prev_from_cursor(&mut self, ctx: &mut ModelContext<Self>) {
match &self.results {
Some(results) if !results.matches.is_empty() => {
if let Some(next_match_index) = self.next_match_index_from_cursor(ctx) {
let prev_match_index = next_match_index
.checked_sub(1)
.unwrap_or_else(|| results.matches.len() - 1);
self.select_internal(prev_match_index, ctx);
}
}
_ => self.clear_selected_result(ctx),
}
}
pub fn select_next_result(&mut self, ctx: &mut ModelContext<Self>) {
let result_count = match &self.results {
Some(results) if !results.matches.is_empty() => results.matches.len(),
_ => return,
};
match self.selected_result.as_ref() {
Some(selected_result) => {
let target_index = (selected_result.current_index + 1) % result_count;
self.select_internal(target_index, ctx);
}
None => self.select_next_from_cursor(ctx),
}
}
pub fn select_previous_result(&mut self, ctx: &mut ModelContext<Self>) {
let result_count = match &self.results {
Some(results) if !results.matches.is_empty() => results.matches.len(),
_ => return,
};
match self.selected_result.as_ref() {
Some(selected_result) => {
let target_index = selected_result
.current_index
.checked_sub(1)
.unwrap_or(result_count - 1);
self.select_internal(target_index, ctx);
}
None => self.select_prev_from_cursor(ctx),
}
}
/// Make `index` the selected search result. This will panic if there are no search results or
/// if `index` is out of bounds for the current result set.
fn select_internal(&mut self, index: usize, ctx: &mut ModelContext<Self>) {
let start_offset = self
.results
.as_ref()
.expect("Must have search results")
.matches[index]
.start;
self.selected_result = Some(self.select_match_at_offset(start_offset, index, ctx));
ctx.emit(SearchEvent::SelectedResultChanged);
}
pub fn select_match_at_offset(
&mut self,
start_offset: CharOffset,
index: usize,
ctx: &mut ModelContext<Self>,
) -> SelectedResult {
let new_anchor = self.selection_model.update(ctx, |selection_model, ctx| {
selection_model.anchor(start_offset, ctx)
});
SelectedResult {
current_index: index,
start: new_anchor,
}
}
fn build_query(&self, ctx: &mut ModelContext<Self>) -> anyhow::Result<Query> {
let config = SearchConfig::new(&self.query_text)
.with_regex(self.regex)
.with_case_sensitive(self.case_sensitive);
self.buffer.as_ref(ctx).prepare_search(&config)
}
fn handle_buffer_event(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
if let BufferEvent::ContentChanged { .. } = event {
self.run_search(ctx);
}
}
#[cfg(test)]
pub fn search_finished(
&self,
ctx: &mut warpui::AppContext,
) -> impl std::future::Future<Output = ()> + use<> {
let maybe_search = self
.search_handle
.as_ref()
.map(|handle| ctx.await_spawned_future(handle.future_id()));
async move {
if let Some(search) = maybe_search {
search.await;
}
}
}
/// Clear the search results.
pub fn reset_results(&mut self, ctx: &mut ModelContext<Self>) {
self.selected_result = None;
self.results = None;
ctx.emit(SearchEvent::Updated);
}
/// Clear the currently selected result without affecting the search results.
pub fn clear_selected_result(&mut self, ctx: &mut ModelContext<Self>) {
self.selected_result = None;
ctx.emit(SearchEvent::SelectedResultChanged);
}
fn run_search(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(handle) = self.search_handle.take() {
handle.abort();
}
if self.query_text.is_empty() {
self.reset_results(ctx);
return;
}
// TODO(ben): Cache and reuse the query.
let search_future = match self.build_query(ctx) {
Ok(query) => self.buffer.as_ref(ctx).search(query),
Err(_) => {
// If we detect an invalid query, clear all existing results until the next successful query.
self.reset_results(ctx);
ctx.emit(SearchEvent::InvalidQuery);
return;
}
};
self.search_handle =
Some(
ctx.spawn(search_future, |me, (_query, results), ctx| match results {
Ok(results) => {
// If a result is currently selected, preserves it between searches
if let Some(selected_result) = me.selected_result.take() {
me.selected_result =
me.restore_selected_result(selected_result, &results, ctx);
}
me.results = Some(results);
if me.auto_select_on_search {
me.select_next_from_cursor(ctx);
}
ctx.emit(SearchEvent::Updated);
}
Err(err) => {
log::warn!("Search failed: {err:?}");
// If the search fails, clear all existing results until the next successful query.
me.reset_results(ctx);
ctx.emit(SearchEvent::Updated);
}
}),
);
}
pub fn restore_selected_result(
&self,
prev_selected_result: SelectedResult,
matches: impl RestorableSearchResults,
ctx: &mut ModelContext<Self>,
) -> Option<SelectedResult> {
if let Some(start_offset) = self
.selection_model
.as_ref(ctx)
.resolve_anchor(&prev_selected_result.start)
{
let candidates = matches.valid_matches().collect_vec();
if let Ok(index) = candidates.binary_search_by_key(&start_offset, |(_, offset)| *offset)
{
return Some(SelectedResult {
current_index: candidates[index].0,
start: prev_selected_result.start,
});
}
}
None
}
/// Builds text decorations to highlight search results.
pub fn result_decorations(&self) -> Vec<Decoration> {
let matches = match &self.results {
Some(results) => &results.matches,
None => return Vec::new(),
};
let selected_match = self.selected_match();
matches
.iter()
.enumerate()
.map(|(index, m)| {
let fill = if Some(index) == selected_match {
*SELECTED_MATCH_FILL
} else {
*MATCH_FILL
};
// TODO(CLD-558): This matches how we shift the selection by 1.
Decoration::new(m.start - 1, m.end - 1).with_background(fill)
})
.collect()
}
pub fn results(&self) -> Option<&SearchResults> {
self.results.as_ref()
}
}
impl Entity for Searcher {
type Event = SearchEvent;
}
+247
View File
@@ -0,0 +1,247 @@
use warpui::{App, ModelHandle};
use crate::content::{
buffer::{AutoScrollBehavior, Buffer, BufferEditAction, BufferSelectAction, EditOrigin},
find::{Match, SearchResults},
selection_model::BufferSelectionModel,
text::IndentBehavior,
};
use super::Searcher;
#[test]
fn test_literal_search() {
App::test((), |mut app| async move {
let (content, selection) = buffer(&mut app, "abc def abc def");
let search = searcher(&mut app, content, selection);
search
.update(&mut app, |search, ctx| {
search.set_query("abc", ctx);
search.search_finished(ctx)
})
.await;
search.read(&app, |search, _| {
assert_eq!(search.match_count(), 2);
assert_eq!(
search.results,
Some(SearchResults {
matches: vec![
Match {
start: 1.into(),
end: 4.into()
},
Match {
start: 9.into(),
end: 12.into()
}
]
})
)
})
});
}
#[test]
fn test_navigate_search_results() {
App::test((), |mut app| async move {
let (content, selection) = buffer(&mut app, "abc def abc def");
let search = searcher(&mut app, content, selection);
search
.update(&mut app, |search, ctx| {
search.set_auto_select(true);
search.set_query("abc", ctx);
search.search_finished(ctx)
})
.await;
search.update(&mut app, |search, ctx| {
assert_eq!(search.match_count(), 2);
// With auto-selection, the first match should be selected (cursor is at start)
assert_eq!(search.selected_match(), Some(0));
// Move to the next result.
search.select_next_result(ctx);
assert_eq!(search.selected_match(), Some(1));
// Keep moving until we wrap around.
search.select_next_result(ctx);
assert_eq!(search.selected_match(), Some(0));
// Now do the same in the other direction.
search.select_previous_result(ctx);
assert_eq!(search.selected_match(), Some(1));
search.select_previous_result(ctx);
assert_eq!(search.selected_match(), Some(0));
// Starting from no selection, select the last result.
search.selected_result = None;
search.select_previous_result(ctx);
assert_eq!(search.selected_match(), Some(1));
})
});
}
#[test]
fn test_edit_results_valid() {
App::test((), |mut app| async move {
let (content, content_selection) = buffer(&mut app, "a git command");
let search = searcher(&mut app, content.clone(), content_selection.clone());
search
.update(&mut app, |search, ctx| {
search.set_auto_select(true);
search.set_regex(true, ctx);
search.set_query(r"g\w+t", ctx);
search.search_finished(ctx)
})
.await;
search.update(&mut app, |search, ctx| {
search.select_next_result(ctx);
assert_eq!(search.selected_match(), Some(0));
});
// Insert some text before the match.
content.update(&mut app, |buffer, ctx| {
buffer.update_selection(
content_selection.clone(),
BufferSelectAction::AddCursorAt {
offset: 2.into(),
clear_selections: true,
},
AutoScrollBehavior::Selection,
ctx,
);
buffer.update_content(
BufferEditAction::Insert {
text: "sdf",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
content_selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<text>asdf git command");
});
search
.update(&mut app, |search, ctx| search.search_finished(ctx))
.await;
// When the search finishes, we should have the same result.
assert_eq!(
search.read(&app, |search, _| search.selected_match()),
Some(0)
);
// Insert a new match before the selected one, which should move it back.
content.update(&mut app, |buffer, ctx| {
buffer.update_content(
BufferEditAction::Insert {
text: "got",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
content_selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<text>asdfgot git command");
});
search
.update(&mut app, |search, ctx| search.search_finished(ctx))
.await;
assert_eq!(
search.read(&app, |search, _| search.selected_match()),
Some(1)
);
})
}
#[test]
fn test_edit_results_invalid() {
App::test((), |mut app| async move {
let (content, content_selection) = buffer(&mut app, "a git command for git");
let search = searcher(&mut app, content.clone(), content_selection.clone());
search
.update(&mut app, |search, ctx| {
search.set_auto_select(true);
search.set_regex(true, ctx);
search.set_query(r"g\w+t", ctx);
search.search_finished(ctx)
})
.await;
search.update(&mut app, |search, ctx| {
// Move to the second match
search.select_next_result(ctx);
assert_eq!(search.selected_match(), Some(1));
});
// Invalidate the match
content.update(&mut app, |buffer, ctx| {
buffer.update_selection(
content_selection.clone(),
BufferSelectAction::AddCursorAt {
offset: 6.into(),
clear_selections: true,
},
AutoScrollBehavior::Selection,
ctx,
);
buffer.update_content(
BufferEditAction::Backspace,
EditOrigin::UserTyped,
content_selection.clone(),
ctx,
);
assert_eq!(buffer.debug(), "<text>a gi command for git");
});
search
.update(&mut app, |search, ctx| search.search_finished(ctx))
.await;
// When the search finishes, auto-selection should pick the remaining match.
search.read(&app, |search, _| {
assert_eq!(search.selected_match(), Some(0));
assert_eq!(search.match_count(), 1);
});
})
}
/// Create a new buffer model with the given Markdown.
fn buffer(
app: &mut App,
markdown: &str,
) -> (ModelHandle<Buffer>, ModelHandle<BufferSelectionModel>) {
let buffer = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(buffer.clone()));
buffer.update(app, |buffer, ctx| {
*buffer = Buffer::from_markdown(
markdown,
None,
Box::new(|_, _| IndentBehavior::Ignore),
selection.clone(),
ctx,
);
});
(buffer, selection)
}
fn searcher(
app: &mut App,
buffer: ModelHandle<Buffer>,
selection: ModelHandle<BufferSelectionModel>,
) -> ModelHandle<Searcher> {
app.add_model(|ctx| Searcher::new(buffer, selection, ctx))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff