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
+68
View File
@@ -0,0 +1,68 @@
[package]
name = "warp_editor"
authors = ["Warp Team <dev@warp.dev>"]
version = "0.1.0"
edition = "2024"
description = "Text editing for Warp"
exclude = ["test_data/*",]
publish.workspace = true
license.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
test-util = []
[dependencies]
anyhow.workspace = true
arrayvec.workspace = true
asset_cache.workspace = true
async-channel.workspace = true
bytes.workspace = true
enum-iterator.workspace = true
futures-lite.workspace = true
float-cmp.workspace = true
instant.workspace = true
itertools.workspace = true
lazy_static.workspace = true
log.workspace = true
mime_guess.workspace = true
num-traits.workspace = true
ordered-float.workspace = true
html5ever.workspace = true
markup5ever = "0.35.0"
markdown_parser.workspace = true
mermaid_to_svg.workspace = true
parking_lot.workspace = true
rand.workspace = true
rangemap.workspace = true
regex-automata = "0.4.3"
regex-syntax = "0.8.2"
urlocator.workspace = true
serde.workspace = true
serde_yaml.workspace = true
string-offset.workspace = true
sum_tree.workspace = true
thiserror.workspace = true
pathfinder_color = "0.5.0"
vec1.workspace = true
warpui.workspace = true
warp_core.workspace = true
warp_util.workspace = true
rayon.workspace = true
line-ending.workspace = true
vim.workspace = true
imara-diff = "0.2"
[dev-dependencies]
rust-embed.workspace = true
enclose = "1.1.8"
env_logger.workspace = true
sum_tree = { workspace = true, features = ["test-util"] }
warp_core = { workspace = true, features = ["test-util"] }
warpui = { workspace = true, features = ["test-util"] }
criterion = { version = "0.5.1", features = ["html_reports"] }
[[bench]]
name = "buffer_bench"
harness = false
+102
View File
@@ -0,0 +1,102 @@
use std::fs;
use criterion::{Criterion, criterion_group, criterion_main};
use rand::{SeedableRng, rngs::StdRng};
use warp_editor::content::{
buffer::Buffer, selection_model::BufferSelectionModel, text::IndentBehavior,
};
use warpui::{App, ModelHandle};
const EDIT_SAMPLE_SIZE: usize = 10;
const MAX_EDIT_REPLACEMENT_LENGTH: usize = 20;
const MAX_INSERTED_TEXT_LENGTH: usize = 10;
const MAX_STYLE_TEXT_LENGTH: usize = 20;
fn mock_buffer(
text: String,
app: &mut App,
) -> (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_plain_text(
&text,
None,
Box::new(|_, _| IndentBehavior::Ignore),
selection.clone(),
ctx,
);
});
(buffer, selection)
}
fn initiate_buffer(text: String) {
App::test((), |mut app| async move {
mock_buffer(text, &mut app);
});
}
fn read_text(text: String) {
App::test((), |mut app| async move {
let (buffer, _) = mock_buffer(text, &mut app);
buffer.read(&app, |buffer, _| buffer.text().into_string());
});
}
fn edit_text(text: String) {
App::test((), |mut app| async move {
let (buffer, selection_model) = mock_buffer(text, &mut app);
buffer.read(&app, |buffer, _| buffer.text().into_string());
let mut seed = StdRng::seed_from_u64(50);
buffer.update(&mut app, |buffer, ctx| {
buffer.random_edit(
EDIT_SAMPLE_SIZE,
&mut seed,
MAX_EDIT_REPLACEMENT_LENGTH,
MAX_INSERTED_TEXT_LENGTH,
selection_model,
ctx,
);
});
});
}
fn style_text(text: String) {
// Disabled: random_style requires ModelHandle and ModelContext which are complex for benchmarks
// let mut seed = StdRng::seed_from_u64(50);
// buffer.random_style(EDIT_SAMPLE_SIZE, &mut seed, MAX_STYLE_TEXT_LENGTH, selection_model, ctx);
App::test((), |mut app| async move {
let (buffer, selection_model) = mock_buffer(text, &mut app);
buffer.read(&app, |buffer, _| buffer.text().into_string());
let mut seed = StdRng::seed_from_u64(50);
buffer.update(&mut app, |buffer, ctx| {
buffer.random_style(
EDIT_SAMPLE_SIZE,
&mut seed,
MAX_STYLE_TEXT_LENGTH,
selection_model,
ctx,
);
});
});
}
fn criterion_benchmark(c: &mut Criterion) {
let text = fs::read_to_string("test_data/test_rust_file.rs").expect("Should work");
c.bench_function("initiate_buffer", |b| {
b.iter(|| initiate_buffer(text.clone()))
});
c.bench_function("read_text", |b| b.iter(|| read_text(text.clone())));
c.bench_function("edit_text", |b| b.iter(|| edit_text(text.clone())));
c.bench_function("style_text", |b| b.iter(|| style_text(text.clone())));
}
criterion_group!(
name = benches;
config = Criterion::default().sample_size(10);
targets = criterion_benchmark
);
criterion_main!(benches);
+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
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
# Test Fixtures
This directory contains test files used for manual testing and validation of the markdown editor.
## Images
The `images/` directory contains sample images and a test markdown file (`image_test.md`) that demonstrates various image rendering scenarios:
- Relative paths (`./sample1.jpg`)
- Parent directory references (`../parent_test.jpg`)
- Absolute paths
- Different image formats (JPG, PNG)
- Images in lists
- Empty alt text
To test image rendering, open `images/image_test.md` in Warp.
@@ -0,0 +1,20 @@
# Test Images
This directory contains test images for validating markdown image rendering.
## Files
- `image_test.md` - Markdown file with various image references
- `sample1.jpg` - 400x300 JPEG with vertical gradient (blue to red-orange) and white border
- `sample2.jpg` - 600x400 JPEG with checkered pattern and orange circle overlay
- `sample3.png` - 300x300 PNG with radial pattern and transparency (alpha channel)
- `parent_test.jpg` - 300x200 JPEG with diagonal blue stripes, located in parent directory for testing relative path resolution
## Purpose
These images test different aspects of image rendering:
- Different formats (JPEG, PNG)
- Different dimensions (300x300, 400x300, 600x400)
- Transparency (PNG with alpha channel)
- Visual patterns to verify proper rendering (gradients, patterns, shapes)
- Relative path resolution (parent directory references)
@@ -0,0 +1,67 @@
# Markdown Image Test
This file tests various ways to include images in markdown.
## Sample JPG Images
Here's a 400x300 sample image:
![Sample Image 1](./sample1.jpg)
Here's a larger 600x400 sample:
![Sample Image 2](./sample2.jpg)
## PNG Image
A square 300x300 PNG:
![Sample PNG](./sample3.png)
## Multiple Images
Let's show multiple images in sequence:
![Sample 1](./sample1.jpg)
![Sample 2](./sample2.jpg)
![Sample 3](./sample3.png)
## Image in a List
Here's a bulleted list with images:
- First item
- ![Inline image in list](./sample1.jpg)
- Third item
## Parent Directory Reference
Here's an image from the parent directory:
![Parent directory image](../parent_test.jpg)
## Absolute Path
You can also use absolute paths (though they're less portable):
![Absolute path](/Users/zach/Projects/warp/editor/test_fixtures/images/sample2.jpg)
## Empty Alt Text
![](./sample3.png)
## Text After Images
Here's some regular text after an image. The image should be rendered inline with the text flow.
![Sample](./sample1.jpg)
This text comes after the image.
---
## End of Test
That covers the basic image rendering scenarios!
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,299 @@
# Mermaid Render Test
This file is meant to exercise a broad variety of Mermaid diagram types in Warp.
Most of the examples below are adapted from `crates/mermaid_to_svg/samples`.
## Flowchart: shapes
```mermaid
graph TD
A[Rect] --> B(Rounded)
B --> C{Diamond}
C --> D((Circle))
D --> E([Stadium])
E --> F[(Cylinder)]
F --> G[[Subroutine]]
G --> H{{Hexagon}}
H --> I>Asymmetric]
```
## Flowchart: styles
```mermaid
graph TD
A[Start] --> B[Process]
B --> C{Decision}
C -->|Yes| D[Success]
C -->|No| E[Error]
D --> F[End]
E --> F
style A fill:#90EE90
style B fill:#87CEEB
style C fill:#FFD700
style D fill:#98FB98
style E fill:#FF6B6B
style F fill:#DDA0DD
```
## Sequence diagram
```mermaid
sequenceDiagram
participant Alice
participant Bob
Note over Alice,Bob: Simple request/response with retry
Alice->>Bob: Request
alt success
Bob-->>Alice: 200 OK
else transient failure
Bob-->>Alice: 503 Retry-After
loop retry up to 3 times
Alice->>Bob: Request
end
end
```
## Class diagram
```mermaid
classDiagram
direction LR
class HttpClient
class ApiClient {
+request()
}
class IService {
<<interface>>
+request()
}
IService <|.. ApiClient
ApiClient --> HttpClient : uses
```
## Entity relationship diagram
```mermaid
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE_ITEM : contains
CUSTOMER {
string name
string custNumber
}
ORDER {
int orderNumber
date orderDate
}
LINE_ITEM {
int quantity
float price
}
```
## State diagram
```mermaid
stateDiagram-v2
[*] --> Validate
Validate --> IsValid
state IsValid <<choice>>
IsValid --> Success : valid
IsValid --> Failure : invalid
state Fork <<fork>>
Success --> Fork
Fork --> TaskA
Fork --> TaskB
state Join <<join>>
TaskA --> Join
TaskB --> Join
Join --> [*]
Failure --> [*]
```
## Journey diagram
```mermaid
journey
title Sprint planning
section Morning
Stand up: 5: Alice, Bob
Review backlog: 3: Alice
Estimate tasks: 1: Bob
section Afternoon
Code review: 4: Alice, Bob
Deploy: 5: Alice
```
## Gantt chart
```mermaid
gantt
title Simple Gantt
dateFormat YYYY-MM-DD
section Build
Setup :a1, 2026-01-01, 2d
Implement :a2, after a1, 5d
Test :a3, after a2, 3d
```
## Pie chart
```mermaid
pie
title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15
```
## Quadrant chart
```mermaid
quadrantChart
title Reach and engagement
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 High impact
quadrant-2 Viral
quadrant-3 Niche
quadrant-4 Broad but shallow
"Post A": [0.3, 0.6]
"Post B": [0.8, 0.2]
```
## Timeline
```mermaid
timeline
title History of Social Platforms
2002 : LinkedIn
2004 : Facebook
2006 : Twitter
```
## Mindmap
```mermaid
mindmap
root((mindmap))
Origins
Long history
Research
On effectiveness
Tooling
Mermaid
Rust
```
## Git graph
```mermaid
gitGraph
commit
commit
branch develop
checkout develop
commit
checkout main
merge develop
```
## Kanban board
```mermaid
kanban
Backlog
Design system@{assigned: Alice, priority: High}
API docs
Todo
Auth flow@{priority: Very High}
Dashboard@{assigned: Bob}
In Progress
Login page@{assigned: Charlie, priority: Medium}
Done
Setup CI@{assigned: Dave, priority: Low}
Database schema
```
## Sankey diagram
```mermaid
sankey-beta
A,B,10
B,C,5
B,D,5
```
## XY chart
```mermaid
xychart-beta
title Demo
x-axis 0 --> 10
y-axis 0 --> 100
line [5, 10, 20, 40]
```
## Requirement diagram
```mermaid
requirementDiagram
direction LR
requirement req1 {
id: 1
text: "The system shall do something"
risk: high
verifyMethod: test
}
element el1 {
type: "Subsystem"
docref: "DOC-1"
}
req1 - satisfies -> el1
```
## C4 context
```mermaid
C4Context
title System Context diagram for Internet Banking System
Person(customer, "Banking Customer", "A customer of the bank")
System(banking, "Internet Banking System", "Allows customers to view information about their bank accounts")
Rel(customer, banking, "Uses")
```
## Block diagram
```mermaid
block-beta
columns 2
A["Node A"]
B["Node B"]
C["Node C"]
D["Node D"]
```
## Packet diagram
```mermaid
packet-beta
0-3: "Header"
4-7: "Payload"
8: "CRC"
```
## Radar chart
```mermaid
radar-beta
axis A, B, C
curve Series1 { 1, 2, 3 }
```
## Info diagram
```mermaid
info
```
## Notes
- Verify that each code fence renders visually instead of as plain text.
- Check which diagram types fully render versus partially render or fall back.
- If useful, compare behavior between editor view, preview view, and reopen/resizing flows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB