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
+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());
}