Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = ¶graph.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();
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user