Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,176 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{
Align, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Icon,
ParentElement, Radius, Shrinkable, Text,
},
geometry::vector::vec2f,
};
use crate::{
editor::EmbeddedItemModel,
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RichTextStyles, viewport::ViewportItem},
},
};
use super::RenderableBlock;
pub struct RenderableBrokenEmbedding {
row: Box<dyn Element>,
viewport_item: ViewportItem,
}
impl RenderableBrokenEmbedding {
pub fn new(
viewport_item: ViewportItem,
styles: &RichTextStyles,
model: Option<&dyn EmbeddedItemModel>,
ctx: &AppContext,
) -> Self {
let icon = ConstrainedBox::new(
Icon::new(
styles.broken_link_style.icon_path,
styles.broken_link_style.icon_color,
)
.with_opacity(1.0)
.finish(),
)
.with_height(styles.base_text.font_size + 2.)
.with_width(styles.base_text.font_size + 2.)
.finish();
let text = Container::new(
Text::new_inline(
"Embed not found",
styles.base_text.font_family,
styles.base_text.font_size,
)
.with_color(styles.placeholder_color)
.finish(),
)
.with_padding_left(8.)
.finish();
let mut row = Flex::row()
.with_child(icon)
.with_child(text)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(element) = model.and_then(|model| model.render_remove_embedding_button(ctx)) {
row.add_child(Shrinkable::new(1., Empty::new().finish()).finish());
row.add_child(Align::new(element).right().finish());
}
Self {
viewport_item,
row: row.finish(),
}
}
}
impl RenderableBlock for RenderableBrokenEmbedding {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &crate::render::model::RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.row.layout(
SizeConstraint::strict(vec2f(
self.viewport_item.content_size.x(),
// Depending on font size, the line height could be bigger
// or smaller than font size + 2. (icon size). Choose the larger
// of the two to avoid failing to layout the element.
model
.styles()
.base_text
.line_height()
.as_f32()
.max(model.styles().base_text.font_size + 2.),
)),
ctx,
app,
);
}
fn paint(
&mut self,
model: &crate::render::model::RenderState,
ctx: &mut super::RenderContext,
app: &warpui::AppContext,
) {
let content = model.content();
let broken_link = extract_block!(self.viewport_item, content, (block, BlockItem::Embedded(item)) => block.embedded(item));
// Render as selected if the broken link is within any selection.
let selected = model.offset_in_active_selection(broken_link.start_char_offset);
// Draw the cursor if the broken link is at any cursor.
let draw_cursor = model.is_selection_head(broken_link.start_char_offset);
let styles = model.styles();
let background_rect = self.viewport_item.visible_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(background_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background(model.styles().embedding_background);
let original_content_origin = broken_link.content_origin();
let vertical_center_delta =
styles.base_text.line_height().as_f32() * (styles.base_text.baseline_ratio - 0.5);
let content_origin = original_content_origin - vec2f(0., vertical_center_delta);
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(background_rect)
.with_background(styles.selection_fill);
}
if draw_cursor {
let line_height = styles.base_text.line_height().as_f32();
// The lower right corner of the background rect is at reserved_origin + background_rect.size()
// Add some horizontal padding and minus line height vertically so it's visible and aligned to
// the bottom of the background rect.
let end_of_line_position =
broken_link.reserved_origin() + background_rect.size() + vec2f(5., -line_height);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(styles.cursor_width, line_height),
CursorData::default(),
styles,
);
}
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
self.row
.paint(ctx.content_to_screen(content_origin), ctx.paint, app);
ctx.paint.scene.stop_layer();
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
self.row.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.row.dispatch_event(event, ctx, app)
}
}
+78
View File
@@ -0,0 +1,78 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::{
element::paint::CursorData,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{
RenderContext, RenderableBlock,
paragraph::paragraph_placeholder_text,
placeholder::{self, BlockPlaceholder},
};
/// Renderable representation of invisible rich-text items. This is used for the trailing newline
/// marker.
pub struct Empty {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl Empty {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(false),
}
}
}
impl RenderableBlock for Empty {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
text: paragraph_placeholder_text(model.selections().len() == 1),
block_style: BufferBlockStyle::PlainText,
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let cursor = extract_block!(self.viewport_item, content, (block, BlockItem::TrailingNewLine(cursor)) => block.trailing_newline(cursor));
if self.placeholder.paint(cursor.content_origin(), model, ctx) {
return;
}
let selections = model.selections();
if selections
.iter()
.any(|selection| selection.is_cursor() && selection.head >= cursor.start_char_offset)
{
let base = &model.styles().base_text;
let cursor_data = CursorData {
block_width: None,
font_size: Some(base.font_size),
};
ctx.draw_and_save_cursor(
ctx.cursor_type,
cursor.content_origin(),
cursor.item.size(),
cursor_data,
model.styles(),
);
}
}
}
@@ -0,0 +1,75 @@
use crate::{
content::text::{BlockHeaderSize, BufferBlockStyle},
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{
RenderContext, RenderableBlock,
placeholder::{BlockPlaceholder, Options},
};
pub struct RenderableHeader {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl RenderableHeader {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableHeader {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
let header_size = match block {
BlockItem::Header { header_size, .. } => *header_size,
other => {
if cfg!(debug_assertions) {
panic!("Expected a header, got {other:?}");
}
BlockHeaderSize::Header6
}
};
Options {
text: header_size.label(),
block_style: BufferBlockStyle::Header { header_size },
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let (paragraph, header_size) = extract_block!(
self.viewport_item, content,
(block, BlockItem::Header { header_size, paragraph }) => (block.header(paragraph), header_size)
);
if self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
return;
}
let header_style = &model.styles().paragraph_styles(&BufferBlockStyle::Header {
header_size: *header_size,
});
ctx.draw_paragraph(&paragraph, header_style, model);
}
}
@@ -0,0 +1,84 @@
use crate::extract_block;
use crate::render::model::BlockItem;
use super::super::model::{RenderState, viewport::ViewportItem};
use super::{RenderContext, RenderableBlock};
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{CrossAxisAlignment, Empty, Flex, ParentElement};
use warpui::{
AfterLayoutContext, AppContext, Element, LayoutContext, SingletonEntity, SizeConstraint,
elements::Container, geometry::vector::vec2f,
};
/// A renderable block for hidden sections that renders a single- or double-line-height rectangle.
/// This is used for BlockItem::Hidden items that need to be visually indicated.
pub struct RenderableHiddenSection {
element: Box<dyn Element>,
viewport_item: ViewportItem,
}
impl RenderableHiddenSection {
pub fn new(viewport_item: ViewportItem, app: &AppContext) -> Self {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let row = Flex::row()
.with_child(Empty::new().finish())
.with_cross_axis_alignment(CrossAxisAlignment::Center);
let element = Container::new(row.finish())
.with_background(internal_colors::fg_overlay_1(theme))
.finish();
Self {
viewport_item,
element,
}
}
}
impl RenderableBlock for RenderableHiddenSection {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, model: &RenderState, ctx: &mut LayoutContext, app: &AppContext) {
let content = model.content();
let hidden_section = extract_block!(self.viewport_item, content, (_block, BlockItem::Hidden(config)) => config);
self.element.layout(
SizeConstraint::strict(vec2f(
model.viewport().width().as_f32(),
hidden_section.height().as_f32(),
)),
ctx,
app,
);
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
// Paint the single- or double-line-height rectangle element
let content_origin = self.viewport_item.content_bounds(ctx).origin()
+ vec2f(model.viewport().scroll_left().as_f32(), 0.);
self.element.paint(content_origin, ctx.paint, app);
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.element.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.element.dispatch_event(event, ctx, app)
}
fn is_hidden_section(&self) -> bool {
true
}
}
@@ -0,0 +1,102 @@
use warpui::{
elements::{CornerRadius, Radius},
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
};
use crate::{
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RenderState, RichTextStyles, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
/// Renderable representation of a single horizontal rule separator.
pub struct HorizontalRule {
viewport_item: ViewportItem,
}
impl HorizontalRule {
pub fn new(viewport_item: ViewportItem) -> Self {
Self { viewport_item }
}
pub fn draw_rect(
content_position: Vector2F,
selected: bool,
draw_cursor: bool,
size: Vector2F,
styles: &RichTextStyles,
ctx: &mut RenderContext,
) {
let rect_origin = ctx.content_to_screen(content_position);
let y_axis_offset = (size.y() - styles.horizontal_rule_style.rule_height).max(0.) / 2.;
let rule_bounds = RectF::new(
vec2f(rect_origin.x(), rect_origin.y() + y_axis_offset),
vec2f(size.x(), styles.horizontal_rule_style.rule_height),
);
let line_bounds = RectF::new(rect_origin, size);
ctx.paint
.scene
.draw_rect_with_hit_recording(rule_bounds)
.with_background(styles.horizontal_rule_style.color)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(line_bounds)
.with_background(styles.selection_fill);
}
if draw_cursor {
let end_of_line_position = content_position + vec2f(size.x(), 0.);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(styles.cursor_width, size.y()),
CursorData::default(),
styles,
);
}
}
}
impl RenderableBlock for HorizontalRule {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let horizontal_rule = extract_block!(self.viewport_item, content, (block, BlockItem::HorizontalRule(rule)) => block.horizontal_rule(rule));
let selected = model.offset_in_active_selection(horizontal_rule.start_char_offset);
let draw_cursor = model.is_selection_head(horizontal_rule.start_char_offset);
Self::draw_rect(
horizontal_rule.content_origin(),
selected,
draw_cursor,
horizontal_rule.item.line_size()
- vec2f(self.viewport_item.spacing.x_axis_offset().as_f32(), 0.),
model.styles(),
ctx,
);
}
}
+104
View File
@@ -0,0 +1,104 @@
use warpui::{
Element, SizeConstraint,
elements::{CacheOption, Image},
geometry::vector::vec2f,
};
use crate::{
extract_block,
render::{
element::paint::{CursorData, CursorDisplayType},
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
pub struct RenderableImage {
viewport_item: ViewportItem,
// TODO: The AssetCache does not currently support automatic eviction of assets when they are
// dropped. We should consider implementing a mechanism to unload images when they are no longer
// visible or referenced.
image_element: Option<Box<dyn Element>>,
}
impl RenderableImage {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
image_element: None,
}
}
}
impl RenderableBlock for RenderableImage {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
let content = model.content();
let (asset_source, config) = extract_block!(
self.viewport_item,
content,
(_block, BlockItem::Image { asset_source, config, .. }) => (asset_source.clone(), *config)
);
let size = vec2f(config.width.as_f32(), config.height.as_f32());
let mut image = Image::new(asset_source, CacheOption::BySize)
.contain()
.first_frame_preview();
let constraint = SizeConstraint::new(vec2f(0., 0.), size);
image.layout(constraint, ctx, app);
self.image_element = Some(Box::new(image));
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let positioned_image = extract_block!(
self.viewport_item,
content,
(block, BlockItem::Image { config, .. }) => block.image(config)
);
let selected = model.offset_in_active_selection(positioned_image.start_char_offset);
let draw_cursor = model.is_selection_head(positioned_image.start_char_offset);
let content_position = positioned_image.content_origin();
let screen_position = ctx.content_to_screen(content_position);
let size = vec2f(
positioned_image.item.width.as_f32(),
positioned_image.item.height.as_f32(),
);
if let Some(ref mut image_element) = self.image_element {
image_element.paint(screen_position, ctx.paint, app);
}
if selected {
let rect_bounds = warpui::geometry::rect::RectF::new(screen_position, size);
ctx.paint
.scene
.draw_rect_with_hit_recording(rect_bounds)
.with_background(model.styles().selection_fill);
}
if draw_cursor {
let end_of_line_position = content_position + vec2f(size.x(), 0.);
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
end_of_line_position,
vec2f(model.styles().cursor_width, size.y()),
CursorData::default(),
model.styles(),
);
}
}
}
@@ -0,0 +1,168 @@
use std::ops::Range;
use warpui::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, ModelHandle,
PaintContext, SizeConstraint, WeakViewHandle,
elements::Point,
event::DispatchedEvent,
geometry::{rect::RectF, vector::Vector2F},
units::IntoPixels,
};
use crate::{
editor::EditorView,
render::{
element::{
DisplayOptions, RenderContext, RenderableBlock, paragraph::RenderableParagraph,
temporary_block::RenderableTemporaryBlock,
},
model::{BlockItem, RenderLineLocation, RenderState},
},
};
pub struct RichTextElementLens<V: EditorView> {
blocks: Option<Vec<Box<dyn RenderableBlock>>>,
line_range: Range<RenderLineLocation>,
pub model: ModelHandle<RenderState>,
display_options: DisplayOptions,
parent_view: WeakViewHandle<V>,
element_size: Option<Vector2F>,
element_origin: Option<Point>,
}
impl<V: EditorView> RichTextElementLens<V> {
pub fn new(
line_range: Range<RenderLineLocation>,
model: ModelHandle<RenderState>,
parent_view: WeakViewHandle<V>,
display_options: DisplayOptions,
) -> Self {
Self {
element_size: None,
element_origin: None,
model,
blocks: None,
parent_view,
display_options,
line_range,
}
}
pub fn blocks(&self) -> Option<&[Box<dyn RenderableBlock>]> {
self.blocks.as_deref()
}
pub fn starting_renderable_block_offset(&self) -> Option<f32> {
self.blocks
.as_ref()
.and_then(|blocks| blocks.first())
.map(|block| block.viewport_item().content_offset.as_f32())
}
}
impl<V: EditorView> Element for RichTextElementLens<V> {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
let model = self.model.as_ref(app);
let mut total_height = 0.;
let blocks =
model.blocks_in_line_range(self.line_range.clone(), constraint.max.x().into_pixels());
// Only support paragraphs and temporary blocks for now. This should be extensible to more block types in the future.
let mut renderable_blocks: Vec<Box<dyn RenderableBlock>> = blocks
.into_iter()
.filter_map(|(item, block)| match block {
BlockItem::Paragraph(_) => Some(RenderableParagraph::new(item).finish()),
BlockItem::TemporaryBlock {
decoration,
text_decoration,
..
} => {
Some(RenderableTemporaryBlock::new(item, decoration, text_decoration).finish())
}
_ => None, /* other block types not supported */
})
.collect();
for block in renderable_blocks.iter_mut() {
block.layout(model, ctx, app);
total_height += block.viewport_item().content_size.y();
}
self.blocks = Some(renderable_blocks);
let size = Vector2F::new(constraint.max.x(), total_height);
self.element_size = Some(size);
size
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
for block in self.blocks.as_mut().unwrap().iter_mut() {
block.after_layout(ctx, app);
}
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
let parent = match self.parent_view.upgrade(app) {
Some(handle) => handle.as_ref(app),
None => {
// TODO: This should really have been an error. But currently in code review it's possible
// for the parent editor view to be dropped before the lens element.
log::debug!("Parent rich-text editor view dropped before paint");
return;
}
};
let model = self.model.as_ref(app);
let viewport_size = self.element_size.unwrap();
self.element_origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
let content_bounds = RectF::new(origin, viewport_size);
// "Mock" the scroll top to be the first block's content offset here so the start of line range is displayed
// top of the element.
let scroll_top = self.starting_renderable_block_offset().unwrap_or(0.);
let mut ctx = RenderContext::new(
content_bounds,
self.display_options.focused,
self.display_options.editable,
false, /* no cursor blink */
Default::default(), /* no cursor type */
parent.text_decorations(
model.viewport_charoffset_range(),
model.next_render_buffer_version(),
app,
),
scroll_top,
viewport_size,
model,
ctx,
None,
&[],
);
for block in self.blocks.as_mut().unwrap().iter_mut() {
block.paint(model, &mut ctx, app);
}
}
fn size(&self) -> Option<Vector2F> {
self.element_size
}
fn origin(&self) -> Option<Point> {
self.element_origin
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
}
+119
View File
@@ -0,0 +1,119 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{Align, CacheOption, CornerRadius, Image, Radius, Text},
geometry::vector::vec2f,
};
use crate::{
extract_block,
render::{
element::paint::CursorData,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{CursorDisplayType, RenderContext, RenderableBlock};
pub struct RenderableMermaidDiagram {
viewport_item: ViewportItem,
image_element: Option<Box<dyn Element>>,
}
impl RenderableMermaidDiagram {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
image_element: None,
}
}
}
impl RenderableBlock for RenderableMermaidDiagram {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, model: &RenderState, ctx: &mut warpui::LayoutContext, app: &AppContext) {
let content = model.content();
let (asset_source, config) = extract_block!(
self.viewport_item,
content,
(_block, BlockItem::MermaidDiagram { asset_source, config, .. }) => (asset_source.clone(), *config)
);
let code_text = model.styles().code_text;
let placeholder = Align::new(
Text::new(
"Rendering Mermaid diagram…",
code_text.font_family,
code_text.font_size,
)
.with_color(model.styles().placeholder_color)
.with_line_height_ratio(code_text.line_height_ratio)
.soft_wrap(false)
.finish(),
)
.finish();
let size = vec2f(config.width.as_f32(), config.height.as_f32());
let mut image = Image::new(asset_source, CacheOption::BySize)
.contain()
.before_load(placeholder);
image.layout(SizeConstraint::strict(size), ctx, app);
self.image_element = Some(Box::new(image));
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
let content = model.content();
let (start_offset, end_offset) = extract_block!(
self.viewport_item,
content,
(block, BlockItem::MermaidDiagram { .. }) => (block.start_char_offset, block.end_char_offset())
);
let visible_rect = self.viewport_item.visible_bounds(ctx);
let content_rect = self.viewport_item.content_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(visible_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(model.styles().code_border)
.with_background(model.styles().code_background);
if let Some(ref mut image_element) = self.image_element {
image_element.paint(content_rect.origin(), ctx.paint, app);
}
let selected = model
.selections()
.iter()
.any(|selection| selection.start() < end_offset && selection.end() + 1 > start_offset);
if selected {
ctx.paint
.scene
.draw_rect_with_hit_recording(content_rect)
.with_background(model.styles().selection_fill);
}
if model.is_selection_head(start_offset) {
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
content_rect.origin(),
vec2f(
model.styles().cursor_width,
self.viewport_item.content_size.y(),
),
CursorData::default(),
model.styles(),
);
}
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
if let Some(ref mut image_element) = self.image_element {
image_element.after_layout(ctx, app);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
use std::sync::Arc;
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::{
layout::TextLayout,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use warpui::elements::ListIndentLevel;
use warpui::{geometry::vector::vec2f, text_layout::TextFrame};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
pub struct RenderableOrderedListItem {
viewport_item: ViewportItem,
number: String,
rendered_number: Option<Arc<TextFrame>>,
placeholder: BlockPlaceholder,
}
impl RenderableOrderedListItem {
pub fn new(indent_level: ListIndentLevel, viewport_item: ViewportItem, number: usize) -> Self {
let number = indent_level.list_number_string(number);
Self {
viewport_item,
number,
rendered_number: None,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableOrderedListItem {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
let text_layout = TextLayout::from_layout_context(ctx, app, model);
let block_style = BufferBlockStyle::OrderedList {
indent_level: ListIndentLevel::One,
number: None,
};
let paragraph_styles = &text_layout.paragraph_styles(&block_style);
let number_text = format!("{}.", self.number);
let style_runs = &[(
0..number_text.chars().count(),
text_layout.style_and_font(paragraph_styles, &Default::default()),
)];
self.rendered_number = Some(text_layout.layout_text(
&number_text,
paragraph_styles,
&self.viewport_item.spacing,
style_runs,
));
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
block_style,
text: "List",
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let paragraph = extract_block!(self.viewport_item, content, (block, BlockItem::OrderedList{ paragraph: inner, ..}) => block.ordered_list(inner));
let text_styling = &model.styles().base_text;
let number = self
.rendered_number
.as_ref()
.expect("Number should be set during layout");
// Position the numeric label in the margin to the left of the item content.
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
let number_origin =
paragraph.content_origin() - vec2f(number.max_width() + space_width, 0.);
ctx.draw_text(number_origin, Default::default(), number, text_styling);
if !self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
ctx.draw_paragraph(&paragraph, text_styling, model);
}
}
}
+345
View File
@@ -0,0 +1,345 @@
//! Utilities for painting rich text.
use std::ops::Range;
use warp_core::ui::appearance::DEFAULT_UI_FONT_SIZE;
use warpui::{
PaintContext,
elements::{CornerRadius, Point, Radius},
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
text_layout::{Line, PaintStyleOverride, TextFrame},
};
use crate::{
editor::TextDecoration,
render::{
layout::line_height,
model::{
Decoration, Paragraph, ParagraphStyles, Positioned, RenderState, RichTextStyles,
saved_positions::SavedPositions,
},
},
};
use string_offset::CharOffset;
use vim::vim::VimMode;
const DEFAULT_BLOCK_CURSOR_WIDTH: f32 = 8.;
/// Cursor display types for vim mode support.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum CursorDisplayType {
#[default]
Bar,
Block,
Underline,
}
/// Cursor data struct for rendering block and underline cursors in vim mode
#[derive(Default)]
pub struct CursorData {
pub block_width: Option<f32>,
pub font_size: Option<f32>,
}
impl CursorData {
/// Unzip cursor data, defaulting to constants if the values are `None`
fn unzip(&self) -> (f32, f32) {
let font_size = self.font_size.unwrap_or(DEFAULT_UI_FONT_SIZE);
let fallback_block_cursor_width =
DEFAULT_BLOCK_CURSOR_WIDTH * (font_size / DEFAULT_UI_FONT_SIZE);
let block_width = self.block_width.unwrap_or(fallback_block_cursor_width);
(font_size, block_width)
}
}
/// Bundle of context needed to render a viewported rich text item.
pub struct RenderContext<'a, 'b> {
/// The on-screen viewport bounds.
pub bounds: RectF,
/// The starting y-offset of content in the current viewport.
pub content_offset: Vector2F,
/// Whether or not the rich text is focused.
pub focused: bool,
/// Whether or not the rich text is editable.
pub editable: bool,
/// Cursor blink state - this is true if cursor blink is disabled _or_ blinking cursors are
/// visible.
blink_on: bool,
/// The cursor type to display
pub cursor_type: CursorDisplayType,
text_decorations: TextDecoration<'a>,
/// Underlying paint context for rendering.
pub paint: &'a mut PaintContext<'b>,
saved_positions: &'a SavedPositions,
pub viewport_size: Vector2F,
/// Current VimMode of the rich text element, if there is one.
pub vim_mode: Option<VimMode>,
/// Vim visual tails - stored cursor positions when entering vim visual mode
pub vim_visual_tails: &'a [CharOffset],
}
impl<'a, 'b> RenderContext<'a, 'b> {
#[allow(clippy::too_many_arguments)]
pub fn new(
bounds: RectF,
focused: bool,
editable: bool,
blink_on: bool,
cursor_type: CursorDisplayType,
text_decorations: TextDecoration<'a>,
scroll_top: f32,
viewport_size: Vector2F,
model: &'a RenderState,
paint: &'a mut PaintContext<'b>,
vim_mode: Option<VimMode>,
vim_visual_tails: &'a [CharOffset],
) -> Self {
Self {
bounds,
// Note that we use the scroll_top directly passed in from the element here because it could
// be updated with the last layout (if the vertical display option is set to grow to max constraint).
// This problem does not exist for vertical scrolls.
content_offset: vec2f(model.viewport().scroll_left().as_f32(), scroll_top),
focused,
editable,
blink_on,
cursor_type,
text_decorations,
paint,
saved_positions: model.saved_positions(),
viewport_size,
vim_mode,
vim_visual_tails,
}
}
pub fn paint_style_override(&self, range: Range<CharOffset>) -> PaintStyleOverride {
self.text_decorations.to_paint_style_override(range)
}
/// Returns the visible bound of the viewport.
pub fn visible_bound(&self) -> RectF {
self.bounds
}
/// Convert a position within the buffer to the corresponding on-screen location.
///
/// Note that the returned location may be out of viewport. The caller is responsible for
/// bounds checking.
pub fn content_to_screen(&self, position: Vector2F) -> Vector2F {
let viewport_relative = position - self.content_offset;
self.bounds.origin() + viewport_relative
}
/// Adjust a buffer-relative rectangle to its on-screen origin.
///
/// Note that the returned rectangle may be out of viewport. The caller is responsible for
/// bounds checking.
pub fn content_rect_to_screen(&self, rect: RectF) -> RectF {
RectF::new(self.content_to_screen(rect.origin()), rect.size())
}
/// Paint a paragraph of text, along with any decorations indicated by the rendering model.
pub fn draw_paragraph(
&mut self,
paragraph: &Positioned<Paragraph>,
style: &ParagraphStyles,
state: &RenderState,
) {
let paint_style_override =
self.paint_style_override(paragraph.start_char_offset..paragraph.end_char_offset());
self.draw_text(
paragraph.content_origin(),
paint_style_override,
paragraph.item.frame(),
style,
);
paragraph.draw_selection(state, self);
self.draw_text_decorations(paragraph, state.decorations().text(), state);
}
/// Helper to draw text decorations over a paragraph. The decorations must be sorted by **end**
/// offset.
fn draw_text_decorations(
&mut self,
paragraph: &Positioned<Paragraph>,
decorations: &[Decoration],
state: &RenderState,
) {
let paragraph_end = paragraph.end_char_offset();
// Because decorations are sorted by their end offset, we binary search to find the last
// decoration that overlaps with the paragraph.
let last_decoration =
decorations.partition_point(|decoration| decoration.end <= paragraph_end);
for decoration in decorations[..last_decoration].iter().rev() {
if decoration.end <= paragraph.start_char_offset {
// Because we're looping backwards, this and all earlier decorations cannot apply
// to the paragraph.
break;
}
if let Some(highlight) = decoration.background {
paragraph.draw_highlight(
decoration.start,
decoration.end,
highlight.into(),
self,
state.max_line(),
);
}
if let Some(color) = decoration.dashed_underline {
paragraph.draw_dashed_underline(decoration.start, decoration.end, color, self);
}
}
}
/// Paint the portion of a [`TextFrame`] that is within the viewport.
///
/// The `content_position` is the position of the frame relative to the buffer's origin
/// (that is, the same as [`Positioned::content_origin`]).
pub fn draw_text(
&mut self,
content_position: Vector2F,
paint_style_override: PaintStyleOverride,
frame: &TextFrame,
style: &ParagraphStyles,
) {
// The origin of the item on the screen, which all lines are painted relative to.
let mut render_origin = self.content_to_screen(content_position);
for (index, line) in frame.lines().iter().enumerate() {
// Order matters for these tests. First, we check if we've
// finished rendering all in-viewport lines, which is true
// once the current line's starting offset is past the max
// render height. Then, we figure out where the current line
// starts and ends. If the end of the line is outside the viewport,
// skip past it. It's important that we still update the origin, otherwise
// we skip the whole paragraph.
if render_origin.y() > self.bounds.max_y() {
// This line is completely below the viewport, and so
// all subsequent lines will be as well.
log::trace!("Lines {index}+ are below viewport, skipping");
break;
}
let line_origin = render_origin;
// Add the line height to the render origin so that we know where the next line begins.
// Conveniently for viewporting, this also tells us where the current line ends.
render_origin.set_y(render_origin.y() + line_height(line));
if render_origin.y() < self.bounds.min_y() {
// This line is completely above the viewport. Skip past
// it until we get to an in-viewport line.
log::trace!("Line {index} is above the viewport, skipping");
continue;
}
log::trace!("Painting line {index}");
line.paint(
RectF::from_points(line_origin, self.bounds.lower_right()),
&paint_style_override,
style.text_color,
self.paint.font_cache,
self.paint.scene,
);
}
}
/// Paint a single [`Line`] of text, if it's within the viewport.
///
/// The `content_position` is the position of the line relative to the buffer's origin (that
/// is, the same as [`Positioned::content_origin`]).
pub fn draw_line(&mut self, content_position: Vector2F, line: &Line, style: &ParagraphStyles) {
// This is a simplified version of the inner loop of [`Self::draw_text`], since there's
// only a single line to consider.
let render_origin = self.content_to_screen(content_position);
if render_origin.y() > self.bounds.max_y() {
log::trace!("Line is below viewport, skipping");
return;
}
if render_origin.y() + line_height(line) < self.bounds.min_y() {
log::trace!("Line is above viewport, skipping");
return;
}
log::trace!("Painting single line");
line.paint(
RectF::from_points(render_origin, self.bounds.lower_right()),
&Default::default(),
style.text_color,
self.paint.font_cache,
self.paint.scene,
);
}
/// Draws a cursor at the content position and save it into the position cache.
///
/// If cursors are not visible, they will be saved but not drawn.
pub fn draw_and_save_cursor(
&mut self,
cursor_display_type: CursorDisplayType,
content_position: Vector2F,
size: Vector2F,
cursor_data: CursorData,
styles: &RichTextStyles,
) {
let (font_size, block_width) = cursor_data.unzip();
let height = size.y();
let cursor_size = match cursor_display_type {
CursorDisplayType::Bar => size,
CursorDisplayType::Block => vec2f(block_width, height),
CursorDisplayType::Underline => vec2f(block_width, height - font_size),
};
let cursor_origin = match cursor_display_type {
CursorDisplayType::Bar => {
// Center the cursor on its origin. This reduces the amount of overlap with glyphs,
// especially for wider cursors.
self.content_to_screen(content_position) - vec2f(size.x() / 2., 0.)
}
CursorDisplayType::Block => self.content_to_screen(content_position),
CursorDisplayType::Underline => {
self.content_to_screen(content_position) + vec2f(0., font_size)
}
};
let bounds = RectF::new(cursor_origin, cursor_size);
let cursor_corner_radius = match cursor_display_type {
CursorDisplayType::Block | CursorDisplayType::Underline => Radius::Pixels(0.),
_ => Radius::Percentage(50.),
};
if self.cursors_visible() {
self.paint
.scene
.draw_rect_with_hit_recording(bounds)
.with_background(styles.cursor_fill)
.with_corner_radius(CornerRadius::with_all(cursor_corner_radius));
}
// The cursor should only exist at one location, so we can save it here.
self.paint
.position_cache
.cache_position_indefinitely(self.saved_positions.cursor_id(), bounds);
}
/// Returns `true` if cursors should be visible.
fn cursors_visible(&self) -> bool {
self.editable && self.focused && self.blink_on
}
/// Tests whether or not any portion of the given rectangle is visible at the current z-index.
pub fn is_visible(&self, rect: RectF) -> bool {
let origin = Point::from_vec2f(rect.origin(), self.paint.scene.z_index());
self.paint.scene.visible_rect(origin, rect.size()).is_some()
}
}
@@ -0,0 +1,76 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
/// The placeholder text to show in empty plain-text blocks.
pub(super) const PARAGRAPH_PLACEHOLDER_TEXT: &str =
"Type text or Markdown, or '/' to insert content";
pub(super) const PARAGRAPH_PLACEHOLDER_TEXT_WITHOUT_SLASH: &str = "Type text or Markdown";
pub fn paragraph_placeholder_text(slash_menu_enabled: bool) -> &'static str {
if slash_menu_enabled {
PARAGRAPH_PLACEHOLDER_TEXT
} else {
PARAGRAPH_PLACEHOLDER_TEXT_WITHOUT_SLASH
}
}
/// [`RenderableBlock`] implementation for `Paragraph` blocks.
pub struct RenderableParagraph {
viewport_item: ViewportItem,
placeholder: BlockPlaceholder,
}
impl RenderableParagraph {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
placeholder: BlockPlaceholder::new(false),
}
}
}
impl RenderableBlock for RenderableParagraph {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |_| {
placeholder::Options {
text: paragraph_placeholder_text(model.selections().len() == 1),
block_style: BufferBlockStyle::PlainText,
}
});
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let paragraph = extract_block!(self.viewport_item, content, (block, BlockItem::Paragraph(inner)) => block.paragraph(inner));
if self
.placeholder
.paint(paragraph.content_origin(), model, ctx)
{
return;
}
let paragraph_styles = &model.styles().base_text;
ctx.draw_paragraph(&paragraph, paragraph_styles, model);
}
}
@@ -0,0 +1,143 @@
use std::sync::Arc;
use warpui::{
AppContext, LayoutContext,
geometry::vector::{Vector2F, vec2f},
text_layout::Line,
};
use crate::{
content::text::BufferBlockStyle,
render::{
element::paint::CursorDisplayType,
layout::{TextLayout, line_height},
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{CursorData, RenderContext};
/// Ghost/placeholder text that's shown in an empty block to provide context.
pub struct BlockPlaceholder {
show_always: bool,
state: State,
}
enum State {
PendingLayout,
LaidOut {
/// Whether or not the block contains the text cursor.
contains_cursor: bool,
/// The placeholder text, laid out as a single clipped line.
line: Arc<Line>,
block_style: BufferBlockStyle,
},
NotShown,
}
impl BlockPlaceholder {
pub fn new(show_always: bool) -> Self {
Self {
show_always,
state: State::PendingLayout,
}
}
/// Lay out the placeholder, if necessary.
pub fn layout<'a, F>(
&mut self,
item: &ViewportItem,
model: &RenderState,
ctx: &mut LayoutContext,
app: &AppContext,
options: F,
) where
F: FnOnce(&BlockItem) -> Options<'a>,
{
debug_assert!(
matches!(self.state, State::PendingLayout),
"Placeholder laid out multiple times"
);
self.state = State::NotShown;
let content = model.content();
let block_offset = item.block_offset();
let block = match content.block_at_offset(block_offset) {
Some(block) if block.item.is_empty() => block,
_ => {
// Placeholders are _never_ shown if the block has user content.
return;
}
};
let contains_cursor = model.selections().iter().any(|selection| {
selection
.single_cursor()
.is_some_and(|cursor| block.contains_content(cursor))
});
if !model.styles().show_placeholder_text_on_empty_block
|| (!self.show_always && !contains_cursor)
{
// If the cursor isn't in this block, don't lay out the placeholder.
return;
}
let layout = TextLayout::from_layout_context(ctx, app, model);
let options = options(block.item);
self.state = State::LaidOut {
line: layout.layout_placeholder(options.text, &options.block_style, &item.spacing),
block_style: options.block_style,
contains_cursor,
};
}
/// Paint this placeholder at the original block's origin. Returns `false` if there is no
/// placeholder, and the regular content should be shown.
pub fn paint(
&self,
content_origin: Vector2F,
model: &RenderState,
ctx: &mut RenderContext,
) -> bool {
match &self.state {
State::NotShown => false,
State::PendingLayout => {
log::warn!("Tried to paint placeholder before layout");
false
}
State::LaidOut {
contains_cursor,
line,
block_style,
} => {
if ctx.editable && (self.show_always || ctx.focused) {
let paragraph_styles = model.styles().paragraph_styles(block_style);
ctx.draw_line(content_origin, line, &paragraph_styles);
}
// If this placeholder contains the cursor, we must draw it, regardless of
// focus (since the cursor positions other UI elements).
if *contains_cursor {
let cursor_size = vec2f(model.styles().cursor_width, line_height(line));
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
content_origin,
cursor_size,
CursorData::default(),
model.styles(),
);
}
true
}
}
}
}
/// Options for displaying a placeholder.
pub struct Options<'a> {
/// The placeholder text.
pub text: &'a str,
/// Block-level styling.
pub block_style: BufferBlockStyle,
}
@@ -0,0 +1,118 @@
use warpui::{
AppContext, Element, SizeConstraint,
elements::{Border, CornerRadius, Empty, Radius},
geometry::vector::vec2f,
};
use crate::{
editor::RunnableCommandModel,
extract_block,
render::{
BLOCK_FOOTER_HEIGHT,
model::{BlockItem, RenderState, viewport::ViewportItem},
},
};
use super::{RenderContext, RenderableBlock};
/// [`RenderableBlock`] implementation for runnable command blocks.
pub struct RenderableRunnableCommand {
viewport_item: ViewportItem,
footer: Box<dyn Element>,
border: Option<Border>,
}
impl RenderableRunnableCommand {
pub fn new(
viewport_item: ViewportItem,
model: Option<&dyn RunnableCommandModel>,
editor_is_focused: bool,
ctx: &AppContext,
) -> Self {
let border = model.as_ref().and_then(|model| model.border(ctx));
let footer = match model {
Some(model) => model.render_block_footer(editor_is_focused, ctx),
None => Empty::new().finish(),
};
Self {
viewport_item,
footer,
border,
}
}
}
impl RenderableBlock for RenderableRunnableCommand {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, _model: &RenderState, ctx: &mut warpui::LayoutContext, app: &AppContext) {
self.footer.layout(
SizeConstraint::strict(vec2f(
self.viewport_item.content_size.x(),
BLOCK_FOOTER_HEIGHT,
)),
ctx,
app,
);
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &AppContext) {
let content = model.content();
let code_block = extract_block!(self.viewport_item, content, (block, BlockItem::RunnableCodeBlock{code_block_type: _, paragraph_block}) => block.code_block(paragraph_block));
let styles = model.styles();
let code_style = &styles.code_text;
let border = if ctx.focused {
self.border.unwrap_or(styles.code_border)
} else {
styles.code_border
};
let background_rect = self.viewport_item.visible_bounds(ctx);
ctx.paint
.scene
.draw_rect_without_hit_recording(background_rect)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(border)
.with_background(model.styles().code_background);
for paragraph in code_block.paragraphs() {
ctx.draw_paragraph(&paragraph, code_style, model);
}
// Place the button at a higher z-index for event handling. See the comment on
// `RichTextElement::content_z_index` for context.
ctx.paint.scene.start_layer(warpui::ClipBounds::ActiveLayer);
// Position the block footer right below the content area, flush with its right-hand edge.
// This gives the footer some padding relative to the visible area with a background.
let content_rect = self.viewport_item.content_bounds(ctx);
let button_origin = content_rect.lower_right()
- vec2f(
self.footer.size().expect("Footer should be laid out").x(),
0.,
);
self.footer.paint(button_origin, ctx.paint, app);
ctx.paint.scene.stop_layer();
}
fn after_layout(&mut self, ctx: &mut warpui::AfterLayoutContext, app: &warpui::AppContext) {
self.footer.after_layout(ctx, app);
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.footer.dispatch_event(event, ctx, app)
}
}
+707
View File
@@ -0,0 +1,707 @@
use std::ops::Range;
use string_offset::CharOffset;
use warpui::{
AppContext, ClipBounds, Event, EventContext,
elements::{
Axis, CornerRadius, DEFAULT_SCROLL_WHEEL_PIXELS_PER_LINE, Radius, ScrollData,
ScrollbarAppearance, ScrollbarGeometry, ScrollbarWidth, compute_scrollbar_geometry,
project_scroll_delta_by_sensitivity, scroll_delta_for_pointer_movement,
},
event::DispatchedEvent,
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::{IntoPixels, Pixels},
};
use crate::render::model::table_offset_map::CellAtOffset;
use crate::{
extract_block,
render::model::{
BlockItem, LaidOutTable, RenderState, RenderedSelection, TableStyle, viewport::ViewportItem,
},
};
use super::{
RenderContext, RenderableBlock,
paint::{CursorData, CursorDisplayType},
};
const TABLE_BORDER_WIDTH: f32 = 1.0;
const TABLE_SCROLL_SENSITIVITY: f32 = 1.0;
pub struct RenderableTable {
viewport_item: ViewportItem,
viewport_bounds: Option<RectF>,
scrollbar: Option<ScrollbarGeometry>,
}
#[cfg(test)]
#[derive(Debug, Clone)]
struct TableLayoutReport {
column_widths: Vec<f32>,
column_lefts: Vec<f32>,
header_height: f32,
row_heights: Vec<f32>,
row_tops: Vec<f32>,
total_height: f32,
}
#[cfg(test)]
fn model_table_layout_report(laid_out_table: &LaidOutTable) -> TableLayoutReport {
let column_widths = laid_out_table
.column_widths
.iter()
.map(|width| width.as_f32())
.collect::<Vec<_>>();
let mut running_left = 0.0;
let column_lefts = column_widths
.iter()
.map(|width| {
let left = running_left;
running_left += *width;
left
})
.collect::<Vec<_>>();
let row_heights = laid_out_table
.row_heights
.iter()
.map(|height| height.as_f32())
.collect::<Vec<_>>();
let header_height = row_heights.first().copied().unwrap_or_default();
let body_row_heights = row_heights.iter().copied().skip(1).collect::<Vec<_>>();
let mut running_top = 0.0;
let row_tops = body_row_heights
.iter()
.map(|height| {
let top = running_top;
running_top += *height;
top
})
.collect::<Vec<_>>();
TableLayoutReport {
column_widths,
column_lefts,
header_height,
row_heights: body_row_heights,
row_tops,
total_height: laid_out_table.total_height.as_f32(),
}
}
#[cfg(test)]
fn row_geometry_from_layout_report(report: &TableLayoutReport) -> (Vec<f32>, Vec<f32>) {
let mut row_tops = Vec::with_capacity(1 + report.row_tops.len());
let mut row_heights = Vec::with_capacity(1 + report.row_heights.len());
row_tops.push(0.0);
row_heights.push(report.header_height);
for (row_top, row_height) in report.row_tops.iter().zip(report.row_heights.iter()) {
row_tops.push(report.header_height + row_top);
row_heights.push(*row_height);
}
(row_tops, row_heights)
}
impl RenderableTable {
pub fn new(viewport_item: ViewportItem) -> Self {
Self {
viewport_item,
viewport_bounds: None,
scrollbar: None,
}
}
}
impl RenderableBlock for RenderableTable {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, _: &RenderState, _: &mut warpui::LayoutContext, _: &AppContext) {}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &AppContext) {
let content = model.content();
let positioned_table = extract_block!(
self.viewport_item,
content,
(block, BlockItem::Table(laid_out_table)) => block.table(laid_out_table)
);
let content_position = positioned_table.content_origin();
let table_start = positioned_table.start_char_offset;
let laid_out_table = positioned_table.item;
let style = &model.styles().table_style;
let viewport_width = table_viewport_width(laid_out_table, &self.viewport_item);
let viewport_bounds = RectF::new(
ctx.content_to_screen(content_position),
vec2f(viewport_width.as_f32(), laid_out_table.height().as_f32()),
);
let visible_content_position =
content_position - vec2f(laid_out_table.scroll_left().as_f32(), 0.0);
let screen_position = ctx.content_to_screen(visible_content_position);
self.viewport_bounds = Some(viewport_bounds);
self.scrollbar = table_scrollbar(laid_out_table, viewport_bounds, viewport_width);
if self.scrollbar.is_none() {
laid_out_table.clear_scrollbar_interaction_state();
}
ctx.paint
.scene
.start_layer(ClipBounds::BoundedByActiveLayerAnd(viewport_bounds));
ctx.paint.scene.set_active_layer_click_through();
paint_backgrounds(laid_out_table, style, screen_position, ctx);
paint_cell_text(laid_out_table, style, screen_position, ctx);
paint_borders(laid_out_table, style, screen_position, ctx);
paint_selection(model, ctx, table_start, laid_out_table, screen_position);
paint_cursor(
model,
ctx,
table_start,
laid_out_table,
visible_content_position,
screen_position,
);
paint_scrollbar(
self.scrollbar,
style,
laid_out_table.scrollbar_hovered() || laid_out_table.scrollbar_drag_state().is_some(),
ctx,
);
ctx.paint.scene.stop_layer();
}
fn dispatch_event(
&mut self,
model: &RenderState,
event: &DispatchedEvent,
ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
let content = model.content();
let Some(block) = content.block_at_offset(self.viewport_item.block_offset()) else {
return false;
};
let BlockItem::Table(laid_out_table) = block.item else {
return false;
};
let viewport_width = table_viewport_width(laid_out_table, &self.viewport_item);
match event.raw_event() {
Event::LeftMouseDown { position, .. } => {
if let Some(scrollbar) = self.scrollbar {
let thumb_hit = scrollbar.thumb_bounds.contains_point(*position);
let track_hit = scrollbar.track_bounds.contains_point(*position);
if thumb_hit {
laid_out_table.start_scrollbar_drag(
position.x().into_pixels(),
table_scroll_data(laid_out_table, viewport_width),
);
ctx.notify();
return true;
}
if track_hit {
let changed = laid_out_table.scroll_horizontally(
scroll_delta_for_pointer_movement(
scrollbar.thumb_center_along(Axis::Horizontal),
position.x().into_pixels(),
table_scroll_data(laid_out_table, viewport_width),
),
viewport_width,
);
if changed {
ctx.notify();
}
return true;
}
}
false
}
Event::LeftMouseDragged { position, .. } => {
let Some(drag_state) = laid_out_table.scrollbar_drag_state() else {
return false;
};
let scroll_delta = scroll_delta_for_pointer_movement(
drag_state.start_position_x,
position.x().into_pixels(),
drag_state.scroll_data,
);
let changed = laid_out_table
.set_scroll_left(drag_state.start_scroll_left - scroll_delta, viewport_width);
if changed {
ctx.notify();
}
true
}
Event::LeftMouseUp { .. } => {
let had_drag = laid_out_table.end_scrollbar_drag();
if had_drag {
ctx.notify();
}
had_drag
}
Event::MouseMoved { position, .. } => {
let hovered = self
.scrollbar
.is_some_and(|scrollbar| scrollbar.thumb_bounds.contains_point(*position));
if laid_out_table.set_scrollbar_hovered(hovered) {
ctx.notify();
}
// MouseMoved should never be consumed here so that downstream handlers
// (hover-link detection, cursor changes, etc.) still receive the event even
// when the pointer is over the scrollbar thumb.
false
}
Event::ScrollWheel {
position,
delta,
precise,
modifiers,
} if !modifiers.ctrl => {
let Some(bounds) = self.viewport_bounds else {
return false;
};
if !bounds.contains_point(*position)
|| laid_out_table.max_scroll_left(viewport_width) <= Pixels::zero()
{
return false;
}
let Some(horizontal_delta) =
table_horizontal_scroll_delta(*delta, *precise, modifiers.shift)
else {
return false;
};
let changed = laid_out_table.scroll_horizontally(horizontal_delta, viewport_width);
if changed {
ctx.notify();
}
// If the table is already pinned at the scroll edge in the direction of the
// delta, returning `false` lets the event fall through to the surrounding
// vertical scroller instead of sticking at the horizontal edge.
changed
}
_ => false,
}
}
}
fn table_viewport_width(laid_out_table: &LaidOutTable, viewport_item: &ViewportItem) -> Pixels {
laid_out_table.viewport_width(Pixels::new(viewport_item.content_size.x()))
}
fn table_scroll_data(laid_out_table: &LaidOutTable, viewport_width: Pixels) -> ScrollData {
ScrollData {
scroll_start: laid_out_table.scroll_left(),
visible_px: laid_out_table.viewport_width(viewport_width),
total_size: laid_out_table.width(),
}
}
fn table_horizontal_scroll_delta(delta: Vector2F, precise: bool, shift: bool) -> Option<Pixels> {
let delta = if shift && delta.x().abs() <= f32::EPSILON {
vec2f(delta.y(), 0.0)
} else {
delta
};
let projected_delta = project_scroll_delta_by_sensitivity(delta, TABLE_SCROLL_SENSITIVITY);
let horizontal_delta = projected_delta.x();
(horizontal_delta.abs() > f32::EPSILON).then_some(Pixels::new(if precise {
horizontal_delta
} else {
horizontal_delta * DEFAULT_SCROLL_WHEEL_PIXELS_PER_LINE
}))
}
fn table_scrollbar(
laid_out_table: &LaidOutTable,
viewport_bounds: RectF,
viewport_width: Pixels,
) -> Option<ScrollbarGeometry> {
let scrollbar = compute_scrollbar_geometry(
Axis::Horizontal,
viewport_bounds.origin(),
viewport_bounds.size(),
table_scroll_data(laid_out_table, viewport_width),
ScrollbarAppearance::new(ScrollbarWidth::Auto, true),
);
scrollbar.has_thumb().then_some(scrollbar)
}
fn paint_scrollbar(
scrollbar: Option<ScrollbarGeometry>,
style: &TableStyle,
active: bool,
ctx: &mut RenderContext,
) {
let Some(scrollbar) = scrollbar else {
return;
};
ctx.paint
.scene
.draw_rect_without_hit_recording(scrollbar.thumb_bounds)
.with_background(if active {
style.scrollbar_active_thumb_color
} else {
style.scrollbar_nonactive_thumb_color
})
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.0)));
}
fn paint_backgrounds(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let total_rows = 1 + laid_out_table.table.rows.len();
for row in 0..total_rows {
let y = row_y_offset(laid_out_table, row);
let h = row_height(laid_out_table, row);
let bg = if row == 0 {
style.header_background
} else if let Some(alt) = style.alternate_row_background {
if row % 2 == 0 {
alt
} else {
style.cell_background
}
} else {
style.cell_background
};
ctx.paint
.scene
.draw_rect_without_hit_recording(RectF::new(
screen_position + vec2f(0.0, y),
vec2f(laid_out_table.width().as_f32(), h),
))
.with_background(bg);
}
}
fn paint_cell_text(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let total_rows = 1 + laid_out_table.table.rows.len();
let num_cols = laid_out_table.column_widths.len();
for row in 0..total_rows {
for col in 0..num_cols {
let is_header = row == 0;
let text_color = if is_header {
style.header_text_color
} else {
style.text_color
};
let Some(frame) = laid_out_table
.cell_text_frames
.get(row)
.and_then(|row_frames| row_frames.get(col))
else {
continue;
};
let cell_content_width = laid_out_table
.column_widths
.get(col)
.map(|w| w.as_f32())
.unwrap_or(0.0)
- style.cell_padding * 2.0;
let cell_content_width = cell_content_width.max(0.0);
let bounds = RectF::new(
screen_position + laid_out_table.cell_content_origin(row, col),
vec2f(cell_content_width, f32::MAX),
);
frame.paint(
bounds,
&Default::default(),
text_color,
ctx.paint.scene,
ctx.paint.font_cache,
);
}
}
}
fn paint_borders(
laid_out_table: &LaidOutTable,
style: &TableStyle,
screen_position: Vector2F,
ctx: &mut RenderContext,
) {
let bw = TABLE_BORDER_WIDTH;
let table_w = laid_out_table.width().as_f32();
let table_h = laid_out_table.total_height.as_f32();
let bc = style.border_color;
let draw = |ctx: &mut RenderContext, rect: RectF| {
ctx.paint
.scene
.draw_rect_without_hit_recording(rect)
.with_background(bc);
};
if style.outer_border {
draw(ctx, RectF::new(screen_position, vec2f(table_w, bw)));
draw(
ctx,
RectF::new(
screen_position + vec2f(0.0, table_h - bw),
vec2f(table_w, bw),
),
);
draw(ctx, RectF::new(screen_position, vec2f(bw, table_h)));
draw(
ctx,
RectF::new(
screen_position + vec2f(table_w - bw, 0.0),
vec2f(bw, table_h),
),
);
}
let total_rows = 1 + laid_out_table.table.rows.len();
if style.row_dividers {
for row in 1..total_rows {
let y = row_y_offset(laid_out_table, row);
draw(
ctx,
RectF::new(screen_position + vec2f(0.0, y), vec2f(table_w, bw)),
);
}
}
if style.column_dividers {
let mut col_x = 0.0f32;
for col in 0..laid_out_table.column_widths.len().saturating_sub(1) {
col_x += laid_out_table.column_widths[col].as_f32();
draw(
ctx,
RectF::new(screen_position + vec2f(col_x, 0.0), vec2f(bw, table_h)),
);
}
}
}
fn paint_selection(
model: &RenderState,
ctx: &mut RenderContext,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
screen_position: Vector2F,
) {
let selections = model.selections();
for selection in selections.iter() {
let Some(selection_range) =
table_selection_relative_range(selection, table_start, laid_out_table)
else {
continue;
};
let relative_start = selection_range.start;
let relative_end = selection_range.end;
let affected_cells = laid_out_table
.offset_map
.cells_in_range(relative_start, relative_end);
for cell in affected_cells {
let Some(cell_offset_map) = laid_out_table
.cell_offset_maps
.get(cell.row)
.and_then(|row_maps| row_maps.get(cell.col))
else {
continue;
};
let cell_char_start_in_table = cell.start;
let cell_char_end_in_table = cell.end;
let sel_start_in_cell = cell_offset_map
.source_to_rendered(if relative_start > cell_char_start_in_table {
relative_start - cell_char_start_in_table
} else {
CharOffset::zero()
})
.as_usize();
let sel_end_in_cell = cell_offset_map
.source_to_rendered(if relative_end < cell_char_end_in_table {
relative_end - cell_char_start_in_table
} else {
cell_char_end_in_table - cell_char_start_in_table
})
.as_usize();
let cell_layout = laid_out_table
.cell_layouts
.get(cell.row)
.and_then(|row_layouts| row_layouts.get(cell.col));
let cell_content_origin =
screen_position + laid_out_table.cell_content_origin(cell.row, cell.col);
let Some(layout) = cell_layout else {
continue;
};
let start_line = layout
.line_at_char_offset(CharOffset::from(sel_start_in_cell))
.unwrap_or(0);
let end_line = layout
.line_at_char_offset(CharOffset::from(sel_end_in_cell.saturating_sub(1)))
.unwrap_or(start_line);
for line_idx in start_line..=end_line {
let line_y = layout.line_y_offsets.get(line_idx).copied().unwrap_or(0.0);
let line_height = layout.line_heights.get(line_idx).copied().unwrap_or(20.0);
let line_start_x = if line_idx == start_line {
layout.x_for_char_in_line(line_idx, sel_start_in_cell)
} else {
0.0
};
let line_end_x = if line_idx == end_line {
layout.x_for_char_in_line(line_idx, sel_end_in_cell)
} else {
let range = layout.line_char_ranges.get(line_idx);
layout
.x_for_char_in_line(line_idx, range.map(|r| r.end.as_usize()).unwrap_or(0))
};
let sel_rect = RectF::new(
vec2f(
cell_content_origin.x() + line_start_x,
cell_content_origin.y() + line_y,
),
vec2f((line_end_x - line_start_x).max(1.0), line_height),
);
ctx.paint
.scene
.draw_rect_without_hit_recording(sel_rect)
.with_background(model.styles().selection_fill);
}
}
}
}
fn paint_cursor(
model: &RenderState,
ctx: &mut RenderContext,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
content_position: Vector2F,
screen_position: Vector2F,
) {
let selections = model.selections();
for selection in selections.iter() {
let Some(relative_offset) =
table_cursor_relative_offset(selection, table_start, laid_out_table)
else {
continue;
};
if let Some(CellAtOffset {
row,
col,
offset_in_cell,
}) = laid_out_table.offset_map.cell_at_offset(relative_offset)
{
let Some(cell_offset_map) = laid_out_table
.cell_offset_maps
.get(row)
.and_then(|row_maps| row_maps.get(col))
else {
continue;
};
let cell_content_origin =
screen_position + laid_out_table.cell_content_origin(row, col);
let Some(cell_layout) = laid_out_table
.cell_layouts
.get(row)
.and_then(|row_layouts| row_layouts.get(col))
else {
continue;
};
let rendered_offset_in_cell = cell_offset_map.source_to_rendered(offset_in_cell);
let offset_in_cell_usize = rendered_offset_in_cell.as_usize();
let line_idx = cell_layout
.line_at_char_offset(rendered_offset_in_cell)
.unwrap_or(0);
let cursor_y_offset = cell_layout
.line_y_offsets
.get(line_idx)
.copied()
.unwrap_or(0.0);
let cursor_height = cell_layout
.line_heights
.get(line_idx)
.copied()
.unwrap_or(20.0);
let cursor_x_offset = cell_layout.x_for_char_in_line(line_idx, offset_in_cell_usize);
let cursor_screen_x = cell_content_origin.x() + cursor_x_offset;
let cursor_screen_y = cell_content_origin.y() + cursor_y_offset;
let cursor_content_x = content_position.x() + (cursor_screen_x - screen_position.x());
let cursor_content_y = content_position.y() + (cursor_screen_y - screen_position.y());
ctx.draw_and_save_cursor(
CursorDisplayType::Bar,
vec2f(cursor_content_x, cursor_content_y),
vec2f(model.styles().cursor_width, cursor_height),
CursorData::default(),
model.styles(),
);
}
}
}
fn table_selection_relative_range(
selection: &RenderedSelection,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
) -> Option<Range<CharOffset>> {
let table_end = table_start + laid_out_table.content_length();
let start = selection.start().max(table_start);
let end = selection.end().min(table_end);
(start < end).then(|| (start - table_start)..(end - table_start))
}
fn table_cursor_relative_offset(
selection: &RenderedSelection,
table_start: CharOffset,
laid_out_table: &LaidOutTable,
) -> Option<CharOffset> {
let table_end = table_start + laid_out_table.content_length();
let head = selection.head;
(head >= table_start && head < table_end).then(|| head - table_start)
}
fn row_y_offset(laid_out_table: &LaidOutTable, row: usize) -> f32 {
laid_out_table
.row_y_offsets
.get(row)
.copied()
.unwrap_or(0.0)
}
fn row_height(laid_out_table: &LaidOutTable, row: usize) -> f32 {
laid_out_table
.row_heights
.get(row)
.map(|h| h.as_f32())
.unwrap_or(20.0)
}
#[cfg(test)]
#[path = "table_tests.rs"]
mod tests;
@@ -0,0 +1,451 @@
use pathfinder_color::ColorU;
use std::{cell::Cell, sync::Arc};
use string_offset::CharOffset;
use warpui::{
elements::{Axis, scroll_delta_for_pointer_movement},
fonts::FamilyId,
geometry::{rect::RectF, vector::vec2f},
text_layout::TextFrame,
units::{IntoPixels, Pixels},
};
use crate::{
content::text::{FormattedTable, table_cell_offset_maps},
render::{
element::table::{
model_table_layout_report, row_geometry_from_layout_report,
table_cursor_relative_offset, table_horizontal_scroll_delta, table_scroll_data,
table_scrollbar, table_selection_relative_range,
},
model::{
BlockSpacing, CellLayout, LaidOutTable, RenderedSelection, TableBlockConfig,
TableStyle, table_offset_map::TableOffsetMap,
},
},
};
fn test_laid_out_table() -> LaidOutTable {
let source = "h1\th2\nr1\tr2\nx\ty\n";
let table = FormattedTable::from_internal_format(source);
let cell_offset_maps = table_cell_offset_maps(&table, source);
let row_heights = vec![24.0.into_pixels(), 30.0.into_pixels(), 42.0.into_pixels()];
let column_widths = vec![80.0.into_pixels(), 120.0.into_pixels()];
let total_height = row_heights
.iter()
.fold(Pixels::zero(), |acc, row_height| acc + *row_height);
let mut row_y_offsets = Vec::with_capacity(row_heights.len() + 1);
row_y_offsets.push(0.0);
let mut running_y = 0.0;
for row_height in &row_heights {
running_y += row_height.as_f32();
row_y_offsets.push(running_y);
}
let mut col_x_offsets = Vec::with_capacity(column_widths.len() + 1);
col_x_offsets.push(0.0);
let mut running_x = 0.0;
for column_width in &column_widths {
running_x += column_width.as_f32();
col_x_offsets.push(running_x);
}
let offset_map = TableOffsetMap::new(
cell_offset_maps
.iter()
.map(|row| {
row.iter()
.map(|cell| cell.source_length().as_usize())
.collect()
})
.collect(),
);
let content_length = offset_map.total_length();
let config = TableBlockConfig {
width: 200.0.into_pixels(),
spacing: BlockSpacing::default(),
style: TableStyle {
border_color: ColorU::new(10, 11, 12, 255),
header_background: ColorU::new(20, 21, 22, 255),
cell_background: ColorU::new(30, 31, 32, 255),
alternate_row_background: None,
text_color: ColorU::new(40, 41, 42, 255),
header_text_color: ColorU::new(50, 51, 52, 255),
scrollbar_nonactive_thumb_color: ColorU::new(60, 61, 62, 255),
scrollbar_active_thumb_color: ColorU::new(70, 71, 72, 255),
font_family: FamilyId(0),
font_size: 14.0,
cell_padding: 6.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
let cell_layouts = vec![vec![CellLayout::default(); 2]; 3];
let cell_text_frames = vec![vec![Arc::new(TextFrame::mock("")); 2]; 3];
LaidOutTable {
table,
config,
row_heights,
column_widths,
total_height,
offset_map,
content_length,
cell_offset_maps,
row_y_offsets,
col_x_offsets,
cell_text_frames,
cell_layouts,
cell_links: vec![
vec![vec![], vec![]],
vec![vec![], vec![]],
vec![vec![], vec![]],
],
scroll_left: Cell::new(Pixels::zero()),
scrollbar_interaction_state: Default::default(),
horizontal_scroll_allowed: true,
}
}
#[test]
fn model_table_layout_report_matches_model_geometry() {
let laid_out_table = test_laid_out_table();
let report = model_table_layout_report(&laid_out_table);
assert_eq!(report.column_widths, vec![80.0, 120.0]);
assert_eq!(report.column_lefts, vec![0.0, 80.0]);
assert_eq!(report.header_height, 24.0);
assert_eq!(report.row_heights, vec![30.0, 42.0]);
assert_eq!(report.row_tops, vec![0.0, 30.0]);
assert_eq!(report.total_height, 96.0);
}
#[test]
fn row_geometry_from_layout_report_includes_header_row() {
let report = model_table_layout_report(&test_laid_out_table());
let (row_tops, row_heights) = row_geometry_from_layout_report(&report);
assert_eq!(row_tops, vec![0.0, 24.0, 54.0]);
assert_eq!(row_heights, vec![24.0, 30.0, 42.0]);
}
#[test]
fn coordinate_to_offset_returns_cell_start_for_first_cell() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(10.0, 5.0);
assert_eq!(offset, CharOffset::zero());
}
#[test]
fn coordinate_to_offset_targets_second_column() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(90.0, 5.0);
let cell_range = table.offset_map.cell_range(0, 1);
assert!(cell_range.is_some());
let range = cell_range.expect("cell (0,1) should exist");
assert!(
offset >= range.start && offset <= range.end,
"offset {offset:?} should be within cell (0,1) range {:?}..{:?}",
range.start,
range.end,
);
}
#[test]
fn coordinate_to_offset_targets_second_row() {
let table = test_laid_out_table();
let offset = table.coordinate_to_offset(10.0, 30.0);
let cell_range = table.offset_map.cell_range(1, 0);
assert!(cell_range.is_some());
let range = cell_range.expect("cell (1,0) should exist");
assert!(
offset >= range.start && offset <= range.end,
"offset {offset:?} should be within cell (1,0) range {:?}..{:?}",
range.start,
range.end,
);
}
#[test]
fn cells_in_range_single_cell() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), CharOffset::from(2usize));
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 0);
}
#[test]
fn cells_in_range_across_row() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), CharOffset::from(5usize));
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 0);
assert_eq!(cells[1].row, 0);
assert_eq!(cells[1].col, 1);
}
#[test]
fn cells_in_range_cross_row() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(3usize), CharOffset::from(9usize));
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].row, 0);
assert_eq!(cells[0].col, 1);
assert_eq!(cells[1].row, 1);
assert_eq!(cells[1].col, 0);
}
#[test]
fn cells_in_range_entire_table() {
let table = test_laid_out_table();
let cells = table
.offset_map
.cells_in_range(CharOffset::from(0usize), table.content_length());
assert_eq!(cells.len(), 6);
}
fn single_line_cell_layout(char_count: usize, line_height: f32, line_width: f32) -> CellLayout {
use warpui::text_layout::CaretPosition;
let mut carets = Vec::with_capacity(char_count);
let char_width = if char_count > 0 {
line_width / char_count as f32
} else {
0.0
};
for i in 0..char_count {
carets.push(CaretPosition {
start_offset: i,
last_offset: i,
position_in_line: i as f32 * char_width,
});
}
CellLayout {
line_heights: vec![line_height],
line_y_offsets: vec![0.0],
line_char_ranges: vec![CharOffset::from(0usize)..CharOffset::from(char_count)],
line_widths: vec![line_width],
line_caret_positions: vec![carets],
}
}
#[test]
fn cell_layout_line_at_char_offset_single_line() {
let layout = single_line_cell_layout(5, 21.0, 50.0);
assert_eq!(
layout.line_at_char_offset(CharOffset::from(0usize)),
Some(0)
);
assert_eq!(
layout.line_at_char_offset(CharOffset::from(4usize)),
Some(0)
);
}
#[test]
fn cell_layout_x_for_char_returns_proportional_position() {
let layout = single_line_cell_layout(4, 21.0, 40.0);
let x0 = layout.x_for_char_in_line(0, 0);
let x2 = layout.x_for_char_in_line(0, 2);
assert!(
x0 < x2,
"x at char 0 ({x0}) should be less than x at char 2 ({x2})"
);
}
#[test]
fn cell_layout_x_past_end_returns_line_width() {
let layout = single_line_cell_layout(3, 21.0, 30.0);
let x = layout.x_for_char_in_line(0, 10);
assert_eq!(x, 30.0);
}
#[test]
fn coordinate_to_offset_with_cell_layouts() {
let mut table = test_laid_out_table();
table.cell_layouts = vec![
vec![
single_line_cell_layout(2, 21.0, 40.0),
single_line_cell_layout(2, 21.0, 40.0),
],
vec![
single_line_cell_layout(2, 21.0, 40.0),
single_line_cell_layout(2, 21.0, 40.0),
],
vec![
single_line_cell_layout(1, 21.0, 20.0),
single_line_cell_layout(1, 21.0, 20.0),
],
];
let offset_start = table.coordinate_to_offset(7.0, 1.0);
let cell_0_0 = table
.offset_map
.cell_range(0, 0)
.expect("cell (0,0) should exist");
assert!(
offset_start >= cell_0_0.start && offset_start <= cell_0_0.end,
"offset at (7,1) should be in cell (0,0)",
);
let offset_col1 = table.coordinate_to_offset(100.0, 30.0);
let cell_1_1 = table
.offset_map
.cell_range(1, 1)
.expect("cell (1,1) should exist");
assert!(
offset_col1 >= cell_1_1.start && offset_col1 <= cell_1_1.end,
"offset at (100,30) should be in cell (1,1)",
);
}
#[test]
fn table_selection_relative_range_starts_at_table_start() {
let table = test_laid_out_table();
let range = table_selection_relative_range(
&RenderedSelection::new(CharOffset::from(5usize), CharOffset::from(6usize)),
CharOffset::from(5usize),
&table,
)
.expect("selection should overlap table");
assert_eq!(range, CharOffset::from(0usize)..CharOffset::from(1usize));
}
#[test]
fn table_selection_relative_range_excludes_non_overlapping_selection() {
let table = test_laid_out_table();
let range = table_selection_relative_range(
&RenderedSelection::new(CharOffset::from(1usize), CharOffset::from(4usize)),
CharOffset::from(5usize),
&table,
);
assert!(range.is_none());
}
#[test]
fn table_cursor_relative_offset_starts_at_zero_for_first_table_character() {
let table = test_laid_out_table();
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(CharOffset::from(5usize), CharOffset::from(5usize)),
CharOffset::from(5usize),
&table,
);
assert_eq!(relative_offset, Some(CharOffset::from(0usize)));
}
#[test]
fn table_cursor_relative_offset_excludes_cursor_before_table_start() {
let table = test_laid_out_table();
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(CharOffset::from(4usize), CharOffset::from(4usize)),
CharOffset::from(5usize),
&table,
);
assert_eq!(relative_offset, None);
}
#[test]
fn table_scrollbar_uses_shared_overlay_geometry() {
let table = test_laid_out_table();
let scrollbar = table_scrollbar(
&table,
RectF::new(vec2f(10.0, 20.0), vec2f(90.0, table.height().as_f32())),
90.0.into_pixels(),
)
.expect("wide table should produce a horizontal scrollbar");
assert_eq!(scrollbar.track_bounds.height(), 12.0);
assert_eq!(scrollbar.track_bounds.width(), 90.0);
assert_eq!(scrollbar.thumb_bounds.height(), 8.0);
assert_eq!(scrollbar.thumb_bounds.origin_y(), 106.0);
assert_eq!(scrollbar.thumb_bounds.width(), 40.5);
}
#[test]
fn table_horizontal_scroll_delta_projects_trackpad_motion_without_vertical_jitter() {
assert_eq!(
table_horizontal_scroll_delta(vec2f(12.0, 4.0), true, false),
Some(12.0.into_pixels()),
);
assert_eq!(
table_horizontal_scroll_delta(vec2f(4.0, 12.0), true, false),
None,
);
}
#[test]
fn table_scrollbar_pointer_movement_matches_drag_and_gutter_behavior() {
let table = test_laid_out_table();
let viewport_width = 90.0.into_pixels();
let scroll_data = table_scroll_data(&table, viewport_width);
let scrollbar = table_scrollbar(
&table,
RectF::new(vec2f(10.0, 20.0), vec2f(90.0, table.height().as_f32())),
viewport_width,
)
.expect("wide table should produce a horizontal scrollbar");
assert_eq!(
scroll_delta_for_pointer_movement(20.0.into_pixels(), 38.0.into_pixels(), scroll_data),
(-40.0).into_pixels(),
);
assert_eq!(
scroll_delta_for_pointer_movement(
scrollbar.thumb_center_along(Axis::Horizontal),
75.25.into_pixels(),
scroll_data,
),
(-100.0).into_pixels(),
);
}
#[test]
fn table_scrollbar_drag_state_survives_renderable_recreation() {
let table = test_laid_out_table();
let viewport_width = 90.0.into_pixels();
table.start_scrollbar_drag(
20.0.into_pixels(),
table_scroll_data(&table, viewport_width),
);
let drag_state = table
.scrollbar_drag_state()
.expect("drag state should persist on the table");
let scroll_delta = scroll_delta_for_pointer_movement(
drag_state.start_position_x,
38.0.into_pixels(),
drag_state.scroll_data,
);
assert!(table.set_scroll_left(drag_state.start_scroll_left - scroll_delta, viewport_width));
assert_eq!(table.scroll_left(), 40.0.into_pixels());
assert!(table.end_scrollbar_drag());
}
#[test]
fn table_cursor_relative_offset_excludes_cursor_at_table_end() {
let table = test_laid_out_table();
let table_start = CharOffset::from(5usize);
let relative_offset = table_cursor_relative_offset(
&RenderedSelection::new(
table_start + table.content_length(),
table_start + table.content_length(),
),
table_start,
&table,
);
assert_eq!(relative_offset, None);
}
@@ -0,0 +1,183 @@
use crate::{
content::text::BufferBlockStyle,
editor::EditorView,
extract_block,
render::model::{BlockItem, RenderState, RichTextStyles, bounds, viewport::ViewportItem},
};
use pathfinder_color::ColorU;
use warpui::elements::ListIndentLevel;
use warpui::{
AppContext, Element, SizeConstraint, WeakViewHandle,
elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, Hoverable, Icon, MouseStateHandle,
Radius, Rect,
},
geometry::vector::vec2f,
platform::Cursor,
};
use super::{
RenderableBlock, RichTextAction,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
// Minimum size constraint for the checkbox point. If the size is smaller than the constraint,
// the svg won't render.
const MIN_CHECK_BOX_SIZE: f32 = 12.;
pub struct RenderableTaskList {
viewport_item: ViewportItem,
task_list_icon: Box<dyn Element>,
icon_size: f32,
placeholder: BlockPlaceholder,
}
impl RenderableTaskList {
pub fn new<V: EditorView>(
complete: bool,
styles: &RichTextStyles,
viewport_item: ViewportItem,
mouse_state: MouseStateHandle,
parent_view: WeakViewHandle<V>,
) -> Self {
let checkmark_icon =
Icon::new(styles.check_box_style.icon_path, ColorU::white()).with_opacity(1.0);
let checkbox_size = styles.base_text.font_size.max(MIN_CHECK_BOX_SIZE);
let (inner, border_width) = if complete {
(checkmark_icon.finish(), 0.)
} else {
(Rect::new().finish(), styles.check_box_style.border_width)
};
let checkbox_length_without_border = checkbox_size - border_width * 2.;
let checkmark_size = checkbox_length_without_border - 3.;
let checkbox = Container::new(
ConstrainedBox::new(
Align::new(
ConstrainedBox::new(inner)
.with_height(checkmark_size)
.with_width(checkmark_size)
.finish(),
)
.finish(),
)
.with_width(checkbox_length_without_border)
.with_height(checkbox_length_without_border)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
.with_border(
Border::all(border_width).with_border_fill(styles.check_box_style.border_color),
);
let block_start = viewport_item.block_offset;
let hoverable = Hoverable::new(mouse_state, |state| {
if complete {
checkbox
.with_background(styles.check_box_style.background)
.finish()
} else if state.is_hovered() {
checkbox
.with_background(styles.check_box_style.hover_background)
.finish()
} else {
checkbox.finish()
}
})
.on_click(move |ctx, app, _| {
if let Some(action) = V::Action::task_list_clicked(block_start, &parent_view, app) {
ctx.dispatch_typed_action(action)
}
})
.with_cursor(Cursor::PointingHand)
.finish();
Self {
viewport_item,
task_list_icon: hoverable,
icon_size: checkbox_size,
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableTaskList {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.task_list_icon.layout(
SizeConstraint::strict(vec2f(self.icon_size, self.icon_size)),
ctx,
app,
);
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
placeholder::Options {
text: "To-do list",
block_style: match block {
BlockItem::TaskList {
indent_level,
complete,
..
} => BufferBlockStyle::TaskList {
indent_level: *indent_level,
complete: *complete,
},
_ => BufferBlockStyle::TaskList {
indent_level: ListIndentLevel::One,
complete: false,
},
},
}
})
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let task_list = extract_block!(self.viewport_item, content, (block, BlockItem::TaskList{ paragraph: inner, ..}) => block.task_list(inner));
let text_styling = &model.styles().base_text;
let line_origin = ctx.content_to_screen(bounds::visible_origin(
task_list.start_y_offset,
&self.viewport_item.spacing,
));
let content_origin = task_list.content_origin();
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
let checkbox_origin = vec2f(
ctx.content_to_screen(content_origin).x() - space_width - self.icon_size,
// Center the checkbox with respect to the first line of text.
line_origin.y() + (text_styling.line_height().as_f32() - self.icon_size) / 2.,
);
self.task_list_icon.paint(checkbox_origin, ctx.paint, app);
if !self.placeholder.paint(content_origin, model, ctx) {
ctx.draw_paragraph(&task_list, text_styling, model);
}
}
fn dispatch_event(
&mut self,
_model: &crate::render::model::RenderState,
event: &warpui::event::DispatchedEvent,
ctx: &mut warpui::EventContext,
app: &AppContext,
) -> bool {
self.task_list_icon.dispatch_event(event, ctx, app)
}
}
@@ -0,0 +1,101 @@
use warp_core::ui::theme::Fill;
use crate::render::model::{BlockItem, Decoration, RenderState, viewport::ViewportItem};
use super::{RenderableBlock, paint::RenderContext};
pub struct RenderableTemporaryBlock {
viewport_item: ViewportItem,
decoration: Option<Fill>,
text_decoration: Vec<Decoration>,
}
impl RenderableTemporaryBlock {
pub fn new(
viewport_item: ViewportItem,
decoration: Option<Fill>,
text_decoration: Vec<Decoration>,
) -> Self {
Self {
viewport_item,
decoration,
text_decoration,
}
}
}
impl RenderableBlock for RenderableTemporaryBlock {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn overlay_decoration(&self) -> Option<Fill> {
self.decoration
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
// We cannot use `extract_block` macro here since we need to locate the viewport item by content height instead of charoffset
// (temporary block has an offset of zero).
let content = model.content();
let paragraph_block = match content.block_at_height(self.viewport_item.height()) {
Some(block) => match (&block, block.item) {
(
block,
BlockItem::TemporaryBlock {
paragraph_block, ..
},
) => block.temporary_block(paragraph_block),
other => {
log::warn!(
"Unexpected block {other:?} at {}",
self.viewport_item.block_offset
);
return;
}
},
None => return,
};
let start = paragraph_block.start_char_offset;
let paragraph_styles = &model.styles().base_text;
let mut decoration_index = 0;
for paragraph in paragraph_block.paragraphs() {
// We could draw text directly since temporary paragraph should have its own decoration and selection state.
ctx.draw_text(
paragraph.content_origin(),
Default::default(),
paragraph.item.frame(),
paragraph_styles,
);
let paragraph_end = paragraph.end_char_offset();
for (idx, decoration) in self.text_decoration[decoration_index..].iter().enumerate() {
if decoration.start + start >= paragraph_end {
decoration_index += idx;
break;
}
if let Some(highlight) = decoration.background {
paragraph.draw_highlight(
decoration.start + start,
decoration.end + start,
highlight.into(),
ctx,
model.max_line(),
);
}
}
}
}
fn is_temporary(&self) -> bool {
true
}
}
@@ -0,0 +1,44 @@
use crate::{
extract_block,
render::model::{BlockItem, RenderState, viewport::ViewportItem},
};
use super::{RenderableBlock, paint::RenderContext};
pub struct RenderableTextBlock {
viewport_item: ViewportItem,
}
impl RenderableTextBlock {
pub fn new(viewport_item: ViewportItem) -> Self {
Self { viewport_item }
}
}
impl RenderableBlock for RenderableTextBlock {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
_model: &RenderState,
_ctx: &mut warpui::LayoutContext,
_app: &warpui::AppContext,
) {
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, _app: &warpui::AppContext) {
let content = model.content();
let text_block = extract_block!(
self.viewport_item,
content,
(block, BlockItem::TextBlock { paragraph_block }) => block.text_block(paragraph_block)
);
let paragraph_styles = &model.styles().base_text;
for paragraph in text_block.paragraphs() {
ctx.draw_paragraph(&paragraph, paragraph_styles, model);
}
}
}
@@ -0,0 +1,124 @@
use crate::{
content::text::BufferBlockStyle,
extract_block,
render::model::{BlockItem, RenderState, RichTextStyles, bounds, viewport::ViewportItem},
};
use warpui::elements::ListIndentLevel;
use warpui::{
Element, SizeConstraint,
elements::{Border, CornerRadius, Radius, Rect},
geometry::vector::vec2f,
};
use super::{
RenderableBlock,
paint::RenderContext,
placeholder::{self, BlockPlaceholder},
};
// Minimum size constraint for the bullet point. If the size is smaller than the constraint,
// the svg won't render.
const MIN_BULLET_POINT_SIZE: f32 = 6.;
pub struct RenderableBulletList {
viewport_item: ViewportItem,
bullet_point: Box<dyn Element>,
bullet_size: f32,
placeholder: BlockPlaceholder,
}
impl RenderableBulletList {
pub fn new(
indent_level: ListIndentLevel,
styles: &RichTextStyles,
viewport_item: ViewportItem,
) -> Self {
let bullet_point = match indent_level {
// Solid bullet point.
ListIndentLevel::One => Rect::new()
.with_background_color(styles.base_text.text_color)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
// Hollow bullet point.
ListIndentLevel::Two => Rect::new()
.with_border(Border::all(2.).with_border_color(styles.base_text.text_color))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
// Solid square.
ListIndentLevel::Three => Rect::new()
.with_background_color(styles.base_text.text_color)
.finish(),
};
Self {
viewport_item,
bullet_point,
bullet_size: (styles.base_text.font_size / 2.).max(MIN_BULLET_POINT_SIZE),
placeholder: BlockPlaceholder::new(true),
}
}
}
impl RenderableBlock for RenderableBulletList {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(
&mut self,
model: &RenderState,
ctx: &mut warpui::LayoutContext,
app: &warpui::AppContext,
) {
self.bullet_point.layout(
SizeConstraint::strict(vec2f(self.bullet_size, self.bullet_size)),
ctx,
app,
);
self.placeholder
.layout(&self.viewport_item, model, ctx, app, |block| {
let indent_level = match block {
BlockItem::UnorderedList { indent_level, .. } => *indent_level,
_ => ListIndentLevel::One,
};
placeholder::Options {
text: "List",
block_style: BufferBlockStyle::UnorderedList { indent_level },
}
})
}
fn paint(&mut self, model: &RenderState, ctx: &mut RenderContext, app: &warpui::AppContext) {
let content = model.content();
let unordered_list = extract_block!(self.viewport_item, content, (block, BlockItem::UnorderedList{ paragraph: inner, ..}) => block.unordered_list(inner));
let text_styling = &model.styles().base_text;
// The real bound of the unordered list could be slightly lower than the bound in the viewport item
// because we position it in the center of the minimum height bound (if the content height is smaller
// than the minimum height).
let line_origin = ctx.content_to_screen(bounds::visible_origin(
unordered_list.start_y_offset,
&self.viewport_item.spacing,
));
let content_origin = unordered_list.content_origin();
let space_width = ctx
.paint
.font_cache
.em_width(text_styling.font_family, text_styling.font_size)
/ 2.;
// Paint the bullet point in the buffer padding to the left of the text frame.
let bullet_origin = vec2f(
ctx.content_to_screen(content_origin).x() - space_width - self.bullet_size,
// Position the bullet point to the middle of the first line in the text frame.
line_origin.y() + text_styling.line_height().as_f32() / 2. - self.bullet_size / 2.,
);
self.bullet_point.paint(bullet_origin, ctx.paint, app);
if !self.placeholder.paint(content_origin, model, ctx) {
ctx.draw_paragraph(&unordered_list, text_styling, model);
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! Shared text-layout utilities needed throughout the editor implementation.
#[cfg(test)]
use markdown_parser::FormattedTextInline;
use std::ops::Range;
use std::sync::Arc;
use crate::content::text::{BufferBlockStyle, TextStylesWithMetadata};
use warpui::fonts::TextLayoutSystem;
#[cfg(test)]
use warpui::fonts::{Style, Weight};
use warpui::text_layout::{
ClipConfig, LayoutCache, Line, StyleAndFont, TextAlignment, TextBorder, TextStyle,
};
use warpui::units::{IntoPixels, Pixels};
use warpui::{AppContext, LayoutContext};
use warpui::{color::ColorU, text_layout::TextFrame};
use super::model::{BlockSpacing, ParagraphStyles, RenderState, RichTextStyles};
const HYPERLINK_UNDERLINE_COLOR: u32 = 0x7aa6daff;
#[derive(Clone, Debug, Default)]
pub(crate) struct InlineTextLayoutInput {
pub text: String,
pub style_runs: Vec<(Range<usize>, StyleAndFont)>,
}
/// Utility for laying out rich text.
pub struct TextLayout<'a> {
layout_cache: &'a LayoutCache,
font_cache: TextLayoutSystem<'a>,
rich_text_styles: &'a RichTextStyles,
max_width: f32,
container_scrolls_horizontally: bool,
}
impl<'a> TextLayout<'a> {
pub fn new(
layout_cache: &'a LayoutCache,
font_cache: TextLayoutSystem<'a>,
rich_text_styles: &'a RichTextStyles,
max_width: f32,
) -> Self {
Self {
layout_cache,
font_cache,
rich_text_styles,
max_width,
container_scrolls_horizontally: false,
}
}
/// Indicate that the surrounding container (for example, a code editor) already provides
/// horizontal scrolling over its full content area. Blocks whose rendering would otherwise
/// introduce a nested horizontal scroll (like wide Markdown tables) should render at their
/// full intrinsic width instead and rely on the container's scroll.
pub fn with_container_scrolls_horizontally(mut self, flag: bool) -> Self {
self.container_scrolls_horizontally = flag;
self
}
/// Whether the surrounding container already provides horizontal scrolling over its full
/// content area. See [`Self::with_container_scrolls_horizontally`].
pub fn container_scrolls_horizontally(&self) -> bool {
self.container_scrolls_horizontally
}
/// Builds a [`TextLayout`] from the context passed to `Element::layout`.
pub fn from_layout_context(
ctx: &LayoutContext<'a>,
app: &'a AppContext,
model: &'a RenderState,
) -> Self {
Self::new(
ctx.text_layout_cache,
app.font_cache().text_layout_system(),
model.styles(),
model.viewport().width().as_f32(),
)
.with_container_scrolls_horizontally(model.container_scrolls_horizontally())
}
/// Lay out a single frame of text. The caller is responsible for mapping rich text into
/// the paragraph's styling and spacing, as well as the per-character styles.
///
/// See [`Self::style_and_font`] for help constructing the `style_runs`.
pub fn layout_text(
&self,
text: &str,
paragraph_style: &ParagraphStyles,
spacing: &BlockSpacing,
style_runs: &[(Range<usize>, StyleAndFont)],
) -> Arc<TextFrame> {
self.layout_text_with_options(
text,
paragraph_style,
style_runs,
self.content_width(spacing),
Default::default(),
)
}
pub fn layout_text_with_options(
&self,
text: &str,
paragraph_style: &ParagraphStyles,
style_runs: &[(Range<usize>, StyleAndFont)],
max_width: f32,
alignment: TextAlignment,
) -> Arc<TextFrame> {
if text.is_empty() {
return Arc::new(TextFrame::empty(
paragraph_style.font_size,
paragraph_style.line_height_ratio,
));
}
self.layout_cache.layout_text(
text,
paragraph_style.line_style(),
style_runs,
max_width,
f32::MAX,
alignment,
None,
&self.font_cache,
)
}
/// Lays out placeholder text for empty blocks.
pub fn layout_placeholder(
&self,
text: &str,
block_type: &BufferBlockStyle,
spacing: &BlockSpacing,
) -> Arc<Line> {
let paragraph_styles = self.paragraph_styles(block_type);
let style_and_font = self.style_and_font(
&paragraph_styles,
&TextStylesWithMetadata::default().for_placeholder(),
);
let style_runs = &[(0..text.chars().count(), style_and_font)];
self.layout_cache.layout_line(
text,
paragraph_styles.line_style(),
style_runs,
self.content_width(spacing),
ClipConfig::end(),
&self.font_cache,
)
}
/// Returns the maximum width for text content laid out with the given spacing.
fn content_width(&self, spacing: &BlockSpacing) -> f32 {
self.max_width - spacing.x_axis_offset().as_f32()
}
/// The paragraph-level styling to use for blocks of the given kind.
pub fn paragraph_styles(&self, block_type: &BufferBlockStyle) -> ParagraphStyles {
self.rich_text_styles.paragraph_styles(block_type)
}
/// Given the [paragraph-level](ParagraphStyles) and [text-level](TextStyles) styles applicable
/// to a range of text, build the [`StyleAndFont`] configuration for laying out that text.
pub fn style_and_font(
&self,
paragraph_styles: &ParagraphStyles,
text_styles: &TextStylesWithMetadata,
) -> StyleAndFont {
let font_properties = text_styles.apply_properties(paragraph_styles.properties());
let font_family = if text_styles.is_inline_code() {
self.rich_text_styles.inline_code_style.font_family
} else {
paragraph_styles.font_family
};
let mut styling = TextStyle::default();
if text_styles.is_placeholder() {
styling = styling.with_foreground_color(self.rich_text_styles.placeholder_color);
}
if text_styles.is_strikethrough() {
styling = styling.with_show_strikethrough(true);
}
if text_styles.is_underlined() {
styling = styling.with_underline_color(self.rich_text_styles.base_text.text_color);
}
if text_styles.is_inline_code() {
styling = styling
.with_foreground_color(self.rich_text_styles.inline_code_style.font_color)
.with_background_color(self.rich_text_styles.inline_code_style.background)
.with_border(TextBorder {
color: self.rich_text_styles.inline_code_style.background,
radius: 4,
width: 1,
// Use the set 1.2 line height ratio for inline code backgrounds.
line_height_ratio_override: Some(120),
});
}
if let Some(color) = text_styles.color() {
styling = styling.with_syntax_color(color);
}
let style_and_font = StyleAndFont::new(font_family, font_properties, styling);
if text_styles.is_link() {
add_link_to_style_and_font(style_and_font)
} else {
style_and_font
}
}
pub fn rich_text_styles(&self) -> &'a RichTextStyles {
self.rich_text_styles
}
pub fn max_width(&self) -> Pixels {
self.max_width.into_pixels()
}
}
/// The line height for a line of text. In CSS terminology, this is the height of the
/// [line box](https://www.w3.org/TR/css-inline-3/#line-box). In typographic terms,
/// it should correspond to the distance between the [top](https://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font)
/// of one line and the top of the next.
///
/// Unlike [`Line::height`], this height does not depend on the specific text in the line. The
/// `height` field measures the line's actual bounds, so it depends on whether glyphs go below
/// the baseline or above the [cap line](https://www.canva.com/learn/typography-terms/).
pub(crate) fn line_height(line: &Line) -> f32 {
line.font_size * line.line_height_ratio
}
pub(crate) fn add_link_to_style_and_font(mut style: StyleAndFont) -> StyleAndFont {
let hyperlink_color = ColorU::from_u32(HYPERLINK_UNDERLINE_COLOR);
style.style = style
.style
.with_underline_color(hyperlink_color)
.with_foreground_color(hyperlink_color);
style
}
#[cfg(test)]
pub(crate) fn markdown_inline_to_text_and_style_runs(
inline: &FormattedTextInline,
paragraph_style: &ParagraphStyles,
link_color: Option<ColorU>,
inline_code_background: Option<ColorU>,
) -> InlineTextLayoutInput {
let mut text = String::new();
let mut style_runs = Vec::new();
let mut start = 0usize;
for fragment in inline {
if fragment.text.is_empty() {
continue;
}
text.push_str(&fragment.text);
let len = fragment.text.chars().count();
let end = start + len;
let mut properties = paragraph_style.properties();
if let Some(custom_weight) = fragment.styles.weight {
properties = properties.weight(Weight::from_custom_weight(Some(custom_weight)));
}
if fragment.styles.italic {
properties = properties.style(Style::Italic);
}
let mut text_style = TextStyle::new();
if fragment.styles.strikethrough {
text_style = text_style.with_show_strikethrough(true);
}
if fragment.styles.underline {
text_style = text_style.with_underline_color(paragraph_style.text_color);
}
if fragment.styles.inline_code
&& let Some(background) = inline_code_background
{
text_style = text_style.with_background_color(background);
}
if fragment.styles.hyperlink.is_some()
&& let Some(link_color) = link_color
{
text_style = text_style
.with_foreground_color(link_color)
.with_underline_color(link_color);
}
style_runs.push((
start..end,
StyleAndFont::new(paragraph_style.font_family, properties, text_style),
));
start = end;
}
if text.is_empty() {
style_runs.push((
0..0,
StyleAndFont::new(
paragraph_style.font_family,
paragraph_style.properties(),
TextStyle::new(),
),
));
}
InlineTextLayoutInput { text, style_runs }
}
+17
View File
@@ -0,0 +1,17 @@
//! Rich Text Editor rendering layer - model and UI element for rendering
//! marked-up rich text.
pub mod element;
pub mod layout;
pub mod model;
/// The size for icon buttons within the rich-text editor. This is needed for both layout and
/// painting, so it's defined here.
pub const ICON_BUTTON_SIZE: f32 = 24.;
pub const BLOCK_FOOTER_HEIGHT: f32 = 42.;
pub(crate) const TABLE_LINE_HEIGHT_RATIO: f32 = 1.5;
pub(crate) const TABLE_BASELINE_RATIO: f32 = 0.8;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+592
View File
@@ -0,0 +1,592 @@
//! End-to-end editor tests.
use warp_core::features::FeatureFlag;
use warpui::{App, ModelHandle, ReadModel};
use crate::content::{
buffer::{
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin,
InitialBufferState,
},
selection_model::BufferSelectionModel,
text::{BlockType, BufferBlockItem, IndentBehavior, TextStyles},
version::BufferVersion,
};
use string_offset::CharOffset;
use super::model::{
BlockItem, RenderEvent, RenderState,
test_utils::{TEST_STYLES, init_logging},
};
use crate::content::buffer::ShouldAutoscroll;
#[test]
fn test_simple_edit() {
init_logging();
App::test((), |mut app| async move {
let state = TestState::new(&mut app);
state
.edit(
BufferEditAction::Insert {
text: "x",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
&mut app,
)
.await;
// See comments in EditDelta::layout_delta on why this paragraph has two characters even
// though it (a) doesn't include the initial `<text>` marker and (b) doesn't end in an
// explicit newline.
state.assert_rendered(
&app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
"#,
);
});
}
#[test]
fn test_edit_many_lines() {
init_logging();
App::test((), |mut app| async move {
let state = TestState::new(&mut app);
// Reset with several lines of Markdown at once.
state
.markdown(
r#"a
bb
ccc
dddd
eeeee
ffffff
ggggggg
hhhhhhhh
iiiiiiiii
jjjjjjjjjj
kkkkkkkkkkk
llllllllllll
mmmmmmmmmmmmm
nnnnnnnnnnnnnn
ooooooooooooooo
pppppppppppppppp
qqqqqqqqqqqqqqqqq
rrrrrrrrrrrrrrrrrr
sssssssssssssssssss
tttttttttttttttttttt
uuuuuuuuuuuuuuuuuuuuu
vvvvvvvvvvvvvvvvvvvvvv
wwwwwwwwwwwwwwwwwwwwwww
xxxxxxxxxxxxxxxxxxxxxxxx
yyyyyyyyyyyyyyyyyyyyyyyyy
zzzzzzzzzzzzzzzzzzzzzzzzzz"#,
&mut app,
)
.await;
// Assert that paragraphs are laid out in the correct order.
state.assert_rendered(
&app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
-------- 32.00px / 2 characters --------
Paragraph (3 characters, 1 lines, 32.00px tall)
-------- 64.00px / 5 characters --------
Paragraph (4 characters, 1 lines, 32.00px tall)
-------- 96.00px / 9 characters --------
Paragraph (5 characters, 1 lines, 32.00px tall)
-------- 128.00px / 14 characters --------
Paragraph (6 characters, 1 lines, 32.00px tall)
-------- 160.00px / 20 characters --------
Paragraph (7 characters, 1 lines, 32.00px tall)
-------- 192.00px / 27 characters --------
Paragraph (8 characters, 1 lines, 32.00px tall)
-------- 224.00px / 35 characters --------
Paragraph (9 characters, 1 lines, 32.00px tall)
-------- 256.00px / 44 characters --------
Paragraph (10 characters, 1 lines, 32.00px tall)
-------- 288.00px / 54 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 320.00px / 65 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
-------- 352.00px / 77 characters --------
Paragraph (13 characters, 1 lines, 32.00px tall)
-------- 384.00px / 90 characters --------
Paragraph (14 characters, 1 lines, 32.00px tall)
-------- 416.00px / 104 characters --------
Paragraph (15 characters, 1 lines, 32.00px tall)
-------- 448.00px / 119 characters --------
Paragraph (16 characters, 1 lines, 32.00px tall)
-------- 480.00px / 135 characters --------
Paragraph (17 characters, 1 lines, 32.00px tall)
-------- 512.00px / 152 characters --------
Paragraph (18 characters, 1 lines, 32.00px tall)
-------- 544.00px / 170 characters --------
Paragraph (19 characters, 1 lines, 32.00px tall)
-------- 576.00px / 189 characters --------
Paragraph (20 characters, 1 lines, 32.00px tall)
-------- 608.00px / 209 characters --------
Paragraph (21 characters, 1 lines, 32.00px tall)
-------- 640.00px / 230 characters --------
Paragraph (22 characters, 1 lines, 32.00px tall)
-------- 672.00px / 252 characters --------
Paragraph (23 characters, 1 lines, 32.00px tall)
-------- 704.00px / 275 characters --------
Paragraph (24 characters, 1 lines, 32.00px tall)
-------- 736.00px / 299 characters --------
Paragraph (25 characters, 1 lines, 32.00px tall)
-------- 768.00px / 324 characters --------
Paragraph (26 characters, 1 lines, 32.00px tall)
-------- 800.00px / 350 characters --------
Paragraph (27 characters, 1 lines, 32.00px tall)
"#,
);
});
}
#[test]
fn test_enter_before_horizontal_rule() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(11, app); // At the end of "First line".
state
.edit(
BufferEditAction::Enter {
force_newline: false,
style: Default::default(),
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Paragraph (1 characters, 1 lines, 32.00px tall)
-------- 64.00px / 12 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 82.00px / 13 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_enter_after_horizontal_rule() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(13, app); // At the end of "First line".
state
.edit(
BufferEditAction::Enter {
force_newline: false,
style: Default::default(),
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 50.00px / 12 characters --------
Paragraph (1 characters, 1 lines, 32.00px tall)
-------- 82.00px / 13 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_edit_at_horizontal_rule_end() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state.markdown("First line\n---\nSecond line", app).await;
state.set_cursor(12, app); // At the end of "First line".
state
.edit(
BufferEditAction::Insert {
text: "x",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (11 characters, 1 lines, 32.00px tall)
-------- 32.00px / 11 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 50.00px / 12 characters --------
Paragraph (2 characters, 1 lines, 32.00px tall)
-------- 82.00px / 14 characters --------
Paragraph (12 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_edit_after_style() {
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state
.markdown("Some **styled** text\nAnd `more`", app)
.await;
// Set the cursor to just after the bold text.
state.set_cursor(12, app);
// Insert some new text with style inheritance.
state
.edit(
BufferEditAction::Insert {
text: "!",
style: TextStyles::default().bold(),
override_text_style: None,
},
EditOrigin::UserTyped,
app,
)
.await;
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (18 characters, 1 lines, 32.00px tall)
-------- 32.00px / 18 characters --------
Paragraph (9 characters, 1 lines, 32.00px tall)
"#,
);
})
}
#[test]
fn test_undo_at_block_boundary() {
// This is a regression test for CLD-1178.
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
state
.markdown("- [x] A\n- [x] B\n- [ ] C\n- [ ] D", app)
.await;
state.assert_buffer(app, "<cl0:true>A<cl0:true>B<cl0:false>C<cl0:false>D<text>");
// Select from the start of item C up through A and B.
state.set_cursor(5, app);
state.select(BufferSelectAction::SetLastHead { offset: 1.into() }, app);
// Press backspace, deleting A and B.
state
.edit(BufferEditAction::Backspace, EditOrigin::UserTyped, app)
.await;
state.assert_buffer(app, "<cl0:true>C<cl0:false>D<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 18.00px / 2 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 36.00px / 4 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
);
// Undo that change and ensure we revert to the original contents.
state
.edit(BufferEditAction::Undo, EditOrigin::UserInitiated, app)
.await;
state.assert_buffer(app, "<cl0:true>A<cl0:true>B<cl0:false>C<cl0:false>D<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 18.00px / 2 characters --------
Task List @ 1 [X] (2 characters, 1 lines, 18.00px tall)
-------- 36.00px / 4 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 54.00px / 6 characters --------
Task List @ 1 [ ] (2 characters, 1 lines, 18.00px tall)
-------- 72.00px / 8 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
)
});
}
#[test]
fn test_convert_first_line() {
// This is a full-stack analogue to test_remove_prefix_and_insert_block_item.
init_logging();
App::test((), |mut app| async move {
let app = &mut app;
let state = TestState::new(app);
// This only uses 2 dashes so it's not parsed as Markdown yet.
state.markdown("--\n```\ncode\n```\n", app).await;
state.assert_buffer(app, "<text>--<code:Shell>code<text>");
// Mimic a Markdown shortcut on the first line.
state.set_cursor(3, app);
state
.edit(
BufferEditAction::RemovePrefixAndStyleBlocks(BlockType::Item(
BufferBlockItem::HorizontalRule,
)),
EditOrigin::UserInitiated,
app,
)
.await;
state.assert_buffer(app, "<hr><code:Shell>code<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Horizontal Rule (1 characters, 1 lines, 18.00px tall)
-------- 18.00px / 1 characters --------
Code Block - Shell (5 characters, 1 lines, 84.00px tall)
-------- 102.00px / 6 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
);
// Undo that change and ensure we revert to the original contents.
state
.edit(BufferEditAction::Undo, EditOrigin::UserInitiated, app)
.await;
state.assert_buffer(app, "<text>--<code:Shell>code<text>");
state.assert_rendered(
app,
r#"
-------- 0.00px / 0 characters --------
Paragraph (3 characters, 1 lines, 32.00px tall)
-------- 32.00px / 3 characters --------
Code Block - Shell (5 characters, 1 lines, 84.00px tall)
-------- 116.00px / 8 characters --------
Trailing Newline (1 characters, 1 lines, 32.00px tall)
"#,
)
});
}
/// Helper for testing edits end-to-end. This is essentially a stripped-down editor model.
struct TestState {
content: ModelHandle<Buffer>,
selection: ModelHandle<BufferSelectionModel>,
render: ModelHandle<RenderState>,
layout_updates: async_channel::Receiver<()>,
}
impl TestState {
fn new(app: &mut App) -> Self {
let content = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection = app.add_model(|_| BufferSelectionModel::new(content.clone()));
let render = app.add_model(|ctx| RenderState::new(TEST_STYLES, false, None, ctx));
let (layout_tx, layout_rx) = async_channel::unbounded();
app.update(|ctx| {
let render2 = render.clone();
ctx.subscribe_to_model(&content, move |_, event, ctx| match event {
BufferEvent::SelectionChanged { .. } => (),
BufferEvent::ContentChanged {
delta,
should_autoscroll,
..
} => render2.update(ctx, |render_state, _| {
render_state.add_pending_edit(delta.clone(), BufferVersion::new());
if matches!(should_autoscroll, ShouldAutoscroll::Yes) {
render_state.request_autoscroll();
}
}),
BufferEvent::AnchorUpdated { .. } | BufferEvent::ContentReplaced { .. } => (),
});
let content2 = content.clone();
ctx.subscribe_to_model(&render, move |render_state, event, ctx| match event {
RenderEvent::NeedsResize => {
let delta = content2.as_ref(ctx).invalidate_layout();
render_state.update(ctx, |render_state, _| {
render_state.add_pending_edit(delta, BufferVersion::new())
});
}
RenderEvent::LayoutUpdated => {
let _ = layout_tx.try_send(());
}
_ => (),
});
});
Self {
content,
selection,
render,
layout_updates: layout_rx,
}
}
/// Move the cursor to an offset.
fn set_cursor(&self, location: impl Into<CharOffset>, app: &mut App) {
self.select(
BufferSelectAction::AddCursorAt {
offset: location.into(),
clear_selections: true,
},
app,
);
}
fn select(&self, action: BufferSelectAction, app: &mut App) {
self.content.update(app, |buffer, ctx| {
buffer.update_selection(
self.selection.clone(),
action,
AutoScrollBehavior::Selection,
ctx,
);
});
}
/// Apply an edit to the buffer and wait for it to be laid out.
async fn edit(&self, action: BufferEditAction<'_>, origin: EditOrigin, app: &mut App) {
self.content.update(app, |buffer, ctx| {
buffer.update_content(action, origin, self.selection.clone(), ctx)
});
self.layout_updates
.recv()
.await
.expect("Layout channel should not be closed");
}
/// Replace the buffer with the given Markdown.
async fn markdown(&self, markdown: &str, app: &mut App) {
let state = InitialBufferState::markdown(markdown);
self.edit(
BufferEditAction::ReplaceWith(state),
EditOrigin::SystemEdit,
app,
)
.await
}
/// Assert that the render state has the expected contents, as produced by describing its
/// `SumTree` of `BlockItem`s.
#[track_caller]
fn assert_rendered(&self, ctx: &impl ReadModel, expected: &str) {
let rendered = self.render.read(ctx, |render_state, _| {
let content = render_state.content();
let described_content = content.describe_content();
described_content.to_string()
});
// TODO: Consider using https://github.com/rust-analyzer/expect-test.
let rendered = rendered.trim();
let expected = expected.trim();
if rendered != expected {
panic!(
"\nExpected:
====
{expected}
====
Actual:
====
{rendered}
===="
);
}
}
/// Assert that the buffer has the expected contents.
#[track_caller]
fn assert_buffer(&self, ctx: &impl ReadModel, expected: &str) {
let buffer = self.content.read(ctx, |buffer, _| buffer.debug());
let buffer = buffer.trim();
let expected = expected.trim();
if buffer != expected {
panic!(
"\nExpected:
====
{expected}
====
Actual:
====
{buffer}
===="
);
}
}
}
#[test]
fn test_markdown_table_render_starts_at_zero_offset() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let state = TestState::new(&mut app);
state
.markdown("| Name | Age |\n| --- | --- |\n| Alice | 30 |\n", &mut app)
.await;
state.render.read(&app, |render_state, _| {
let content = render_state.content();
let block = content
.block_at_offset(CharOffset::zero())
.expect("table block should exist at offset 0");
assert_eq!(block.start_char_offset, CharOffset::zero());
assert!(matches!(block.item, BlockItem::Table(_)));
});
});
}
#[test]
fn test_markdown_table_count_counts_rendered_tables() {
App::test((), |mut app| async move {
let _flag = FeatureFlag::MarkdownTables.override_enabled(true);
let state = TestState::new(&mut app);
state
.markdown("| Name | Age |\n| --- | --- |\n| Alice | 30 |\n", &mut app)
.await;
let count = state
.render
.read(&app, |render_state, _| render_state.markdown_table_count());
assert_eq!(count, 1);
});
}
+91
View File
@@ -0,0 +1,91 @@
//! Consistent bounds definitions for rich-text blocks.
//!
//! The terminology is loosely based on the [alternative CSS box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model#parts_of_a_box).
//!
//! * The **content box** is the rectangle containing a block's content, without any margins or
//! padding.
//! * The **visible box** is the rectangle containing a block's content, padding, and borders -
//! everything that's visually part of the block.
//! * The **reserved box** is the rectangle containing a block's content, padding, borders, and
//! margin - all space reserved for the block.
use warpui::{
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::Pixels,
};
use super::BlockSpacing;
/// The origin of a block's content box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn content_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
vec2f(
spacing.left_offset().as_f32(),
(y_offset + spacing.top_offset()).as_f32(),
)
}
/// The content box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn content_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(content_origin(y_offset, spacing), content_size)
}
/// The origin of a block's visible box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn visible_origin(y_offset: Pixels, spacing: &BlockSpacing) -> Vector2F {
vec2f(
spacing.margin.left(),
y_offset.as_f32() + spacing.margin.top(),
)
}
/// The visible box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn visible_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(
visible_origin(y_offset, spacing),
content_size
+ vec2f(
spacing.padding.left() + spacing.padding.right(),
spacing.padding.top() + spacing.padding.bottom(),
),
)
}
/// The origin of a block's reserved box. This is relative to the buffer origin. To convert it
/// to an on-screen point, use `RenderContext::content_to_screen`.
pub fn reserved_origin(y_offset: Pixels) -> Vector2F {
vec2f(0., y_offset.as_f32())
}
/// The reserved box for a block, given its:
/// * y-offset relative to the start of the buffer
/// * Content size, in pixels
/// * Spacing
///
/// The box is relative to the buffer. To convert it to an on-screen rectangle, use
/// `RenderContext::content_rect_to_screen`.
pub fn reserved_box(y_offset: Pixels, content_size: Vector2F, spacing: &BlockSpacing) -> RectF {
RectF::new(
reserved_origin(y_offset),
content_size
+ vec2f(
spacing.x_axis_offset().as_f32(),
spacing.y_axis_offset().as_f32(),
),
)
}
+138
View File
@@ -0,0 +1,138 @@
use std::fmt;
use sum_tree::SumTree;
use super::{BlockItem, LayoutSummary, RenderState};
/// Extension trait for types with verbose descriptive formatting.
pub trait Describe {
/// Describe this item into the given formatter.
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result;
/// Describe this item.
fn describe(&self) -> Description<'_, Self> {
Description(self)
}
}
pub struct Description<'a, T: ?Sized>(&'a T);
impl<T: Describe + ?Sized> fmt::Display for Description<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.0.describe_to(f)
}
}
impl Describe for RenderState {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
let viewport_start = self.viewport.scroll_top().as_f32() as f64;
let viewport_end = viewport_start + self.viewport.height().as_f32() as f64;
writeln!(f, "Viewport: {viewport_start:.2}px to {viewport_end:.2}px")?;
writeln!(f, "Selection: {}", self.selections())?;
let mut in_viewport = false;
let content = self.content.borrow();
let mut cursor = content.cursor::<(), LayoutSummary>();
cursor.descend_to_first_item(&content, |_| true);
while let Some(item) = cursor.item() {
let start_summary = cursor.start();
let item_start = start_summary.height;
let item_end = item_start + item.height().as_f32() as f64;
// Is this the end of the viewport?
if item_start > viewport_end && in_viewport {
in_viewport = false;
writeln!(f, "============> VIEWPORT END <============")?;
}
// Is this the start of the viewport?
if item_end >= viewport_start && item_start <= viewport_end && !in_viewport {
in_viewport = true;
writeln!(f, "============> VIEWPORT START <============")?;
}
writeln!(
f,
"-------- {:.2}px / {} characters --------",
start_summary.height, start_summary.content_length
)?;
writeln!(f, " {}", item.describe())?;
cursor.next();
}
if in_viewport {
writeln!(f, "============> VIEWPORT END <============")?;
}
Ok(())
}
}
impl Describe for SumTree<BlockItem> {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut cursor = self.cursor::<(), LayoutSummary>();
cursor.descend_to_first_item(self, |_| true);
while let Some(item) = cursor.item() {
let summary = cursor.start();
writeln!(
f,
"-------- {:.2}px / {} characters --------\n{}",
summary.height,
summary.content_length,
item.describe()
)?;
cursor.next();
}
Ok(())
}
}
impl Describe for BlockItem {
fn describe_to(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BlockItem::Paragraph(_) => f.write_str("Paragraph")?,
BlockItem::TextBlock { .. } => f.write_str("Text Block")?,
BlockItem::RunnableCodeBlock {
code_block_type, ..
} => write!(f, "Code Block - {code_block_type}",)?,
BlockItem::MermaidDiagram { .. } => f.write_str("Mermaid Diagram")?,
BlockItem::TemporaryBlock { .. } => f.write_str("Temporary Paragraph")?,
BlockItem::TaskList {
indent_level,
complete,
..
} => write!(
f,
"Task List @ {indent_level} [{}]",
if *complete { "X" } else { " " }
)?,
BlockItem::UnorderedList { indent_level, .. } => {
write!(f, "Unordered List @ {indent_level}")?
}
BlockItem::OrderedList { indent_level, .. } => {
write!(f, "Ordered List @ {indent_level}")?
}
BlockItem::Header { header_size, .. } => write!(f, "{header_size:?}")?,
BlockItem::HorizontalRule(_) => f.write_str("Horizontal Rule")?,
BlockItem::Image { alt_text, .. } => write!(f, "Image: {alt_text}")?,
BlockItem::Table(laid_out_table) => write!(
f,
"Table: {}x{}",
laid_out_table.table.rows.len() + 1,
laid_out_table.table.headers.len()
)?,
BlockItem::TrailingNewLine(_) => f.write_str("Trailing Newline")?,
BlockItem::Embedded(_) => f.write_str("Embedded Item")?,
BlockItem::Hidden { .. } => f.write_str("Hidden")?,
}
write!(
f,
" ({} characters, {} lines, {:.2}px tall)",
self.content_length(),
self.lines(),
self.height()
)
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Hit-testing implementation for the rendering model.
use num_traits::SaturatingSub;
use sum_tree::SeekBias;
use warpui::units::{IntoPixels, Pixels};
use string_offset::CharOffset;
use super::{
BlockItem, Height, HitTestBlockType, LayoutSummary, ParagraphBlock, RenderState, bounds,
positioned::{Positioned, PositionedCursor},
};
#[cfg(test)]
#[path = "location_tests.rs"]
mod tests;
/// A location within the editor, as resolved by hit-testing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Location {
/// An entire block.
Block {
/// The starting character offset of the block (inclusive).
start_offset: CharOffset,
/// The ending character offset of the block (exclusive).
end_offset: CharOffset,
/// Type of the block that was hit.
block_type: HitTestBlockType,
},
/// An exact location within the content space.
Text {
/// Offset of the hit character.
char_offset: CharOffset,
/// Whether or not we clamped to this location (for example, if the position
/// was after the end of text on a line, or after the end of all text
/// in the editor).
clamped: bool,
/// Cursor disposition for soft-wrapped text.
wrap_direction: WrapDirection,
/// The starting offset of the block that contains the hit character.
block_start: CharOffset,
link: Option<String>,
},
}
impl Location {
/// The starting [`CharOffset`] of the block that was hit. All hits are within a single block.
pub fn block_start(&self) -> CharOffset {
match self {
Location::Block { start_offset, .. } => *start_offset,
Location::Text { block_start, .. } => *block_start,
}
}
}
/// With soft-wrapping, the end of one line and the start of the next have the
/// same character offset. The `WrapDirection` indicates which one a location is
/// at.
///
/// Suppose we have the line `longword`, soft-wrapped to
/// ```text
/// long
/// word
/// ```
///
/// Visually, after `g` and before `w` are two distinct locations. However, they
/// have the same character offset, 4.
///
/// To represent the first location, we wrap up:
/// ```text
/// long|
/// word
/// ```
///
/// To represent the second, we wrap down:
/// ```text
/// long
/// |word
/// ```
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum WrapDirection {
/// Place the cursor at the end of the previous line.
Up,
/// Place the cursor at the start of the next line.
#[default]
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HitTestOptions {
/// If true, clamp block-level selections to text selections. Currently, this only matters for
/// a hit in the padding area of a code block. Normally, that's considered a hit on the block
/// rather than its text.
pub force_text_selection: bool,
}
impl RenderState {
/// Performs hit-testing on coordinates relative to the content origin
/// (that is, non-viewported). The provided `options` configure how the hit-testing behaves.
pub fn render_coordinates_to_location(
&self,
x: Pixels,
y: Pixels,
options: &HitTestOptions,
) -> Location {
let content = self.content.borrow();
let mut block_cursor = content.cursor::<Height, LayoutSummary>();
block_cursor.seek(&y.into(), SeekBias::Left);
let Some(block) = block_cursor.positioned_item() else {
// If we're at the end of the editor, bias towards placing new text
// on a new line.
let char_offset = self.max_offset();
log::debug!("Clamped to end: {char_offset}");
return Location::Text {
char_offset,
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: char_offset,
link: None,
};
};
block.coordinates_to_location(x, y, options)
}
/// Performs hit-testing on coordinates relative to the viewport origin.
pub fn viewport_coordinates_to_location(
&self,
x: Pixels,
y: Pixels,
options: &HitTestOptions,
) -> Location {
self.render_coordinates_to_location(
(x + self.viewport.scroll_left()).max(Pixels::zero()),
(y + self.viewport.scroll_top()).max(Pixels::zero()),
options,
)
}
}
impl<'a> Positioned<'a, BlockItem> {
/// Resolve coordinates to a location, assuming they're within this block.
/// The coordinates are all relative to the content origin.
fn coordinates_to_location(&self, x: Pixels, y: Pixels, options: &HitTestOptions) -> Location {
match self.item {
BlockItem::Paragraph(paragraph) => self
.paragraph(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::TextBlock { paragraph_block } => {
self.location_in_paragraph_block(x, y, self.text_block(paragraph_block))
}
BlockItem::TaskList { paragraph, .. } => self
.task_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::UnorderedList { paragraph, .. } => self
.unordered_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::OrderedList { paragraph, .. } => self
.ordered_list(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::RunnableCodeBlock {
paragraph_block, ..
} => {
// To make text selection more ergonomic, any point on a line with text is
// considered part of the block's text area, including padding. Points within the
// padding above or below a code block's text are considered part of the block
// itself (unless `options.force_text_selection` is true), which allows
// clicking a block to select it.
let text_origin = bounds::content_origin(self.start_y_offset, &self.style);
let text_height_range =
text_origin.y()..=text_origin.y() + paragraph_block.height().as_f32();
if options.force_text_selection || text_height_range.contains(&y.as_f32()) {
// Note: we don't unpad `x` here because it's handled by `location_in_paragraph_block`.
self.location_in_paragraph_block(x, y, self.code_block(paragraph_block))
} else {
Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::Code,
}
}
}
BlockItem::MermaidDiagram { .. } => {
let _ = options;
Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::MermaidDiagram,
}
}
BlockItem::Header { paragraph, .. } => self
.header(paragraph)
.coordinate_to_location(self.unpad_x(x), y),
BlockItem::Embedded(_) => Location::Block {
start_offset: self.start_char_offset,
end_offset: self.end_char_offset(),
block_type: HitTestBlockType::Embedding,
},
BlockItem::Table(laid_out_table) => {
let relative_x = (x.as_f32() - self.content_origin().x()).max(0.0);
let relative_y = (y.as_f32() - self.content_origin().y()).max(0.0);
let cell_offset = laid_out_table.coordinate_to_offset(
relative_x + laid_out_table.scroll_left().as_f32(),
relative_y,
);
let char_offset = self.start_char_offset + cell_offset;
let link = laid_out_table.link_at_offset(cell_offset);
Location::Text {
char_offset,
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: self.start_char_offset,
link,
}
}
BlockItem::HorizontalRule { .. }
| BlockItem::Image { .. }
| BlockItem::TrailingNewLine(_)
| BlockItem::TemporaryBlock { .. }
| BlockItem::Hidden { .. } => Location::Text {
char_offset: self.start_char_offset,
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: self.start_char_offset,
link: None,
},
}
}
fn location_in_paragraph_block(
&self,
x: Pixels,
y: Pixels,
paragraph_block: Positioned<'a, ParagraphBlock>,
) -> Location {
for paragraph in paragraph_block.paragraphs() {
if paragraph.end_y_offset() > y {
let mut location = paragraph.coordinate_to_location(self.unpad_x(x), y);
// Adjust the paragraph-relative start offset to be the start of this block.
if let Location::Text { block_start, .. } = &mut location {
*block_start = self.start_char_offset;
}
return location;
}
}
Location::Text {
char_offset: self.end_char_offset().saturating_sub(&CharOffset::from(1)),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: self.start_char_offset,
link: None,
}
}
/// Remove horizontal padding from an x-coordinate in order to hit-test within a padded
/// paragraph.
fn unpad_x(&self, x: Pixels) -> Pixels {
(x - self.content_origin().x().into_pixels()).max(Pixels::zero())
}
}
@@ -0,0 +1,704 @@
use crate::content::text::{FormattedTable, table_cell_offset_maps};
use crate::{
content::text::{BufferBlockStyle, CodeBlockType},
render::model::{
BlockItem, COMMAND_SPACING, CellLayout, ImageBlockConfig, LaidOutTable, Location,
ParagraphBlock, RenderState, TableBlockConfig, TableStyle,
location::{HitTestBlockType, HitTestOptions, WrapDirection},
table_offset_map,
test_utils::{
TEST_STYLES, laid_out_paragraph, laid_out_unordered_lists, layout_paragraphs,
},
},
};
use pathfinder_color::ColorU;
use std::{cell::Cell, sync::Arc};
use string_offset::CharOffset;
use sum_tree::SumTree;
use warpui::assets::asset_cache::AssetSource;
use warpui::fonts::FamilyId;
use warpui::text_layout::{CaretPosition, TextFrame};
use warpui::units::IntoPixels;
fn test_table_layout() -> LaidOutTable {
let source = "aaa\tbbb\nccc\tddd\n";
let table = FormattedTable::from_internal_format(source);
let cell_offset_maps = table_cell_offset_maps(&table, source);
let offset_map = table_offset_map::TableOffsetMap::new(
cell_offset_maps
.iter()
.map(|row| {
row.iter()
.map(|cell| cell.source_length().as_usize())
.collect()
})
.collect(),
);
let content_length = offset_map.total_length();
let cell_layout = CellLayout {
line_heights: vec![20.0],
line_y_offsets: vec![0.0],
line_char_ranges: vec![CharOffset::from(0)..CharOffset::from(3)],
line_widths: vec![30.0],
line_caret_positions: vec![vec![
CaretPosition {
position_in_line: 0.0,
start_offset: 0,
last_offset: 0,
},
CaretPosition {
position_in_line: 10.0,
start_offset: 1,
last_offset: 1,
},
CaretPosition {
position_in_line: 20.0,
start_offset: 2,
last_offset: 2,
},
]],
};
let cell_frame = Arc::new(TextFrame::mock("aaa"));
LaidOutTable {
table,
config: TableBlockConfig {
width: 60.0.into_pixels(),
spacing: Default::default(),
style: TableStyle {
border_color: ColorU::new(0, 0, 0, 255),
header_background: ColorU::new(0, 0, 0, 255),
cell_background: ColorU::new(0, 0, 0, 255),
alternate_row_background: None,
text_color: ColorU::new(0, 0, 0, 255),
header_text_color: ColorU::new(0, 0, 0, 255),
scrollbar_nonactive_thumb_color: ColorU::new(0, 0, 0, 255),
scrollbar_active_thumb_color: ColorU::new(0, 0, 0, 255),
font_family: FamilyId(0),
font_size: 10.0,
cell_padding: 0.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
},
row_heights: vec![20.0.into_pixels(), 20.0.into_pixels()],
column_widths: vec![30.0.into_pixels(), 30.0.into_pixels()],
total_height: 40.0.into_pixels(),
offset_map,
content_length,
cell_offset_maps,
row_y_offsets: vec![0.0, 20.0, 40.0],
col_x_offsets: vec![0.0, 30.0, 60.0],
cell_text_frames: vec![
vec![cell_frame.clone(), cell_frame.clone()],
vec![cell_frame.clone(), cell_frame],
],
cell_layouts: vec![
vec![cell_layout.clone(), cell_layout.clone()],
vec![cell_layout.clone(), cell_layout],
],
cell_links: vec![vec![vec![], vec![]], vec![vec![], vec![]]],
scroll_left: Cell::new(30.0.into_pixels()),
scrollbar_interaction_state: Default::default(),
horizontal_scroll_allowed: true,
}
}
#[test]
fn test_hit_within_line() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"Hello, world!\n",
&TEST_STYLES,
40.,
)));
assert_eq!(
model.render_coordinates_to_location(
12.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 1.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 2.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_table_hit_testing_accounts_for_horizontal_scroll() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 30.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(BlockItem::Table(Box::new(
test_table_layout(),
))));
assert_eq!(
model.render_coordinates_to_location(
0.0.into_pixels(),
0.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_mermaid_block_uses_block_locations_even_with_forced_text_selection() {
let width = 200.;
let mut model = RenderState::new_for_test(
TEST_STYLES.clone(),
width.into_pixels(),
160.0.into_pixels(),
);
let mermaid = BlockItem::MermaidDiagram {
content_length: 14.into(),
asset_source: AssetSource::Bundled {
path: "bundled/svg/test.svg",
},
config: ImageBlockConfig {
width: 120.0.into_pixels(),
height: 40.0.into_pixels(),
spacing: COMMAND_SPACING,
},
};
let mermaid_height = mermaid.height().as_f32();
model.set_content(SumTree::from_item(mermaid));
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
(mermaid_height / 2.0).into_pixels(),
&Default::default()
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
1.0.into_pixels(),
&HitTestOptions {
force_text_selection: true,
}
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
assert_eq!(
model.render_coordinates_to_location(
40.0.into_pixels(),
(mermaid_height - 1.0).into_pixels(),
&HitTestOptions {
force_text_selection: true,
}
),
Location::Block {
start_offset: 0.into(),
end_offset: 14.into(),
block_type: HitTestBlockType::MermaidDiagram,
}
);
}
#[test]
fn test_hit_within_list() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_unordered_lists(
"Hello, world!\n",
&TEST_STYLES,
40.,
)));
assert_eq!(
model.render_coordinates_to_location(
18.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 0.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
assert_eq!(
model.render_coordinates_to_location(
29.0.into_pixels(),
4.3.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 1.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_empty_line() {
// This is a regression test for CLD-591.
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
// Height: 0-32, chars: 0-4
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
// Height: 32-64, chars: 4-5
laid_out_paragraph("\n", &TEST_STYLES, 40.0),
// Height: 64-96, chars: 5-9
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
]);
model.set_content(tree);
// A hit on the empty line should clamp to within that line, not the start of the next one.
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
40.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::from(4),
link: None,
}
);
}
#[test]
fn test_hit_on_soft_wrapped_line() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 60.0.into_pixels(), 30.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"Hello, world!\n",
&TEST_STYLES,
40., // This is less than the viewport width to account for the 20px of margin.
)));
// A hit just after the end of a soft-wrapped line should wrap up.
assert_eq!(
model.render_coordinates_to_location(
46.0.into_pixels(),
6.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::zero(),
link: None,
}
);
// A hit at the start of the next line should have the same char offset, but wrap down.
assert_eq!(
model.render_coordinates_to_location(
0.0.into_pixels(),
15.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_after_end() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 50.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"ABCD\n",
&TEST_STYLES,
50.,
)));
// A hit after the end, but on the same line, is like a soft-wrapped hit.
assert_eq!(
model.render_coordinates_to_location(
45.0.into_pixels(),
10.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: CharOffset::zero(),
link: None,
}
);
// A hit on the line after the last clamps to the end of content, but wraps
// to the placeholder next line.
assert_eq!(
model.render_coordinates_to_location(
20.0.into_pixels(),
33.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None,
}
);
}
#[test]
fn test_hit_before_start() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 60.0.into_pixels());
model.set_content(SumTree::from_item(laid_out_paragraph(
"ABCDEFGH\n",
&TEST_STYLES,
model.viewport().width().as_f32(),
)));
// Hit before the start of the first soft-wrapped line, which should clamp to the first
// character.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
10.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 0.into(),
// Not considered clamped, because BlockItem::coordinates_to_location pre-clamps when handling
// padding.
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 0.into(),
link: None,
}
);
// Hit before the start of the second soft-wrapped line, which should clamp to the first
// character of that line.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
20.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 4.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 0.into(),
link: None
}
);
}
#[test]
fn test_hit_scrolled() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 42.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
// Height: 0-32, chars: 0-3
laid_out_paragraph("1st\n", &TEST_STYLES, 40.0),
// Height: 32-64, chars: 4-7
laid_out_paragraph("2nd\n", &TEST_STYLES, 40.0),
// Height: 64-96, chars: 8-15
laid_out_paragraph("wrapped\n", &TEST_STYLES, 40.0),
// Height: 96-128, chars: 16-20
laid_out_paragraph("last\n", &TEST_STYLES, 40.0),
]);
model.set_content(tree);
// Scroll the viewport directly since we don't have a ModelContext.
model.viewport.scroll((-40.0).into_pixels(), model.height());
// Because of scrolling, this hits the second line.
assert_eq!(
model.viewport_coordinates_to_location(
22.0.into_pixels(),
0.2.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 6.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 4.into(),
link: None,
}
);
// Now, scroll to the last viewport (containing the last paragraph, part of "ped", and the
// trailing newline).
model.viewport.scroll((-47.0).into_pixels(), model.height());
// This line is soft-wrapped to be partially in-viewport.
assert_eq!(
model.viewport_coordinates_to_location(
36.0.into_pixels(),
0.5.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 15.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 8.into(),
link: None,
}
);
// We should still be able to hit-test at the last line - accounting for the scroll position,
// this is the very end of it.
assert_eq!(
model.viewport_coordinates_to_location(
0.5.into_pixels(),
20.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 16.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 16.into(),
link: None,
}
);
// Hits past the last line should resolve to the last character in the buffer. In this case,
// that's a newline, but in a real editor, it would be the last character of the last line,
// with a TrailingNewline marker after it.
assert_eq!(
model.viewport_coordinates_to_location(
2.0.into_pixels(),
42.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 21.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: 21.into(),
link: None,
}
);
}
#[test]
fn test_hit_padding() {
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), 40.0.into_pixels(), 40.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([laid_out_paragraph("line\n", &TEST_STYLES, 40.0)]);
model.set_content(tree);
// Hit in the padding after the paragraph ends. We should return the character that
// matches the x-axis pixel position on the last line.
assert_eq!(
model.viewport_coordinates_to_location(
38.0.into_pixels(),
22.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 3.into(),
clamped: true,
wrap_direction: WrapDirection::Down,
block_start: CharOffset::zero(),
link: None,
}
);
}
#[test]
fn test_hit_code_block() {
let width = 200.;
let mut model =
RenderState::new_for_test(TEST_STYLES.clone(), width.into_pixels(), 20.0.into_pixels());
let mut tree = SumTree::new();
tree.extend([
laid_out_paragraph("Text\n", &TEST_STYLES, width),
BlockItem::RunnableCodeBlock {
paragraph_block: ParagraphBlock::new(layout_paragraphs(
"Code 1\nCode 2",
&TEST_STYLES,
&BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Shell,
},
width - COMMAND_SPACING.x_axis_offset().as_f32(),
)),
code_block_type: Default::default(),
},
]);
model.set_content(tree);
// Blocks by height:
// * 0-32: First paragraph
// * 32-56: Margin above code block
// * 56-66: First line of code
// * 66-76: Second line of code
// * 76-92: Margin below code block
// The code block is inset by 16px.
// Hits within the code block should have the right start location.
assert_eq!(
model.render_coordinates_to_location(
30.0.into_pixels(),
70.0.into_pixels(),
&Default::default()
),
Location::Text {
// The hit should be on the "o" on the second line of code.
char_offset: 13.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
// The code block starts at offset 5.
block_start: 5.into(),
link: None,
}
);
// Hits within the code block's margin are treated as block selections.
assert_eq!(
model.render_coordinates_to_location(
10.0.into_pixels(),
50.0.into_pixels(),
&Default::default()
),
Location::Block {
start_offset: 5.into(),
end_offset: 19.into(),
block_type: HitTestBlockType::Code
}
);
// Hits in the horizontal padding are considered part of the text.
assert_eq!(
model.render_coordinates_to_location(
8.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
assert_eq!(
model.render_coordinates_to_location(
90.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 11.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 5.into(),
link: None
}
);
// The above rule holds even for out-of-bounds points.
assert_eq!(
model.render_coordinates_to_location(
(-4.).into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 5.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
assert_eq!(
model.render_coordinates_to_location(
1000.0.into_pixels(),
60.0.into_pixels(),
&Default::default()
),
Location::Text {
char_offset: 11.into(),
clamped: true,
wrap_direction: WrapDirection::Up,
block_start: 5.into(),
link: None
}
);
// If block selection is disabled (e.g. due to dragging), we still clamp to text.
assert_eq!(
model.render_coordinates_to_location(
27.0.into_pixels(),
50.0.into_pixels(),
&HitTestOptions {
force_text_selection: true
}
),
Location::Text {
char_offset: 6.into(),
clamped: false,
wrap_direction: WrapDirection::Down,
block_start: 5.into(),
link: None
}
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
use std::ops::{Add, Sub};
use itertools::Itertools;
use num_traits::SaturatingSub;
use string_offset::CharOffset;
use super::FrameOffset;
#[cfg(test)]
#[path = "offset_map_tests.rs"]
mod tests;
/// Mapping between visible character offsets and interactive content character offsets. Due to
/// placeholder text, not all characters painted on the screen correspond to interactive characters
/// in the content model.
///
/// The mapping model assumes that, within the overall set of characters in a text frame, only
/// certain character runs are interactive. Within those runs, there's a 1:1 mapping between
/// `char`s in the content model and `char`s in the text frame. It translates in two directions:
/// * From a [`CharOffset`] relative to the start of the content model block (a [`super::Paragraph`])
/// to the character index in the [`warpui::text_layout::TextFrame`].
/// * From a [`FrameOffset`] in the `TextFrame` to the closest `CharOffset` in the content model
/// block (for example, clicking within a placeholder should snap the cursor to a regular content
/// character).
///
/// An interactive character is one that the user can select and edit.
#[derive(Debug, Clone)]
pub struct OffsetMap {
/// Sorted list of interactive content runs.
runs: Vec<SelectableTextRun>,
}
/// A run of selectable/interactive content in the [`OffsetMap`]. This includes all user-written
/// characters, but not placeholder text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectableTextRun {
/// The offset of the first content-character in this run.
pub content_start: CharOffset,
/// The offset of the first [`TextFrame`] visible character in this run.
pub frame_start: FrameOffset,
/// The total number of characters in this run. The run length is the same for both content
/// characters and text frame characters. That is, this run represents:
/// * Characters `content_start..content_start+length` within the containing paragraph
/// * Characters `frame_start..frame_start+length` within the containing `TextFrame`
pub length: usize,
}
impl OffsetMap {
/// Creates a new [`OffsetMap`] that translates 1:1 between visible character offsets and
/// interactive content character offsets.
pub fn direct(length: usize) -> Self {
Self::new(vec![SelectableTextRun {
content_start: CharOffset::zero(),
frame_start: FrameOffset::zero(),
length,
}])
}
pub fn new(mut runs: Vec<SelectableTextRun>) -> Self {
runs.sort_unstable_by_key(|r| r.frame_start);
if cfg!(debug_assertions) {
// Content- and frame-offsets should both increase from run to run.
for (a, b) in runs.iter().tuple_windows() {
assert!(a.content_start < b.content_start, "Runs must be ascending");
}
}
Self { runs }
}
/// Translate a visible character to the closest content character.
pub fn to_content(&self, offset: FrameOffset) -> CharOffset {
self.translate(offset)
}
/// Translate a content character to the corresponding visible character.
pub fn to_frame(&self, offset: CharOffset) -> FrameOffset {
// When going from content to frame offsets, the character is almost always going to be
// inside an interactive run. The exception is the placeholder marker character, and so
// it's simpler to reuse the translation algorithm from the other direction rather than
// detect and handle this special case.
self.translate(offset)
}
/// Translate from one kind of offset to the other. If the source offset is within an interactive
/// run, this returns the exact location of the character within that run. Otherwise, it rounds
/// up or down to the nearest interactive run.
fn translate<T: ParagraphOffset, U: ParagraphOffset>(&self, offset: T) -> U {
// Find the runs before and after `offset`, and calculate for each:
// - The absolute distance from it to `offset`
// - The translated offset according to it
let after_idx = self.runs.partition_point(|run| T::start(run) < offset);
let after = self.runs.get(after_idx).map(|run| {
let distance = T::start(run) - offset;
// If the run after `offset` is closer, then snap to the start of that run (e.g. the
// first regular character after a placeholder).
(distance, U::start(run))
});
let before = after_idx
.checked_sub(1)
.and_then(|idx| self.runs.get(idx))
.map(|run| {
// For the before run, we measure the distance from its end to offset. However, the
// offset could be within the run, so we clamp down to 0.
let run_end = T::start(run) + run.length;
let distance = offset.saturating_sub(&run_end);
// If we pick the before run, we essentially rebase it onto the destination start, but then
// clamp to stay within the run.
let translation =
U::start(run) + (offset - T::start(run)).as_usize().min(run.length);
(distance, translation)
});
match (before, after) {
(None, None) => U::zero(), // This should only happen if the frame is empty.
(None, Some((_, translation))) => translation,
(Some((_, translation)), None) => translation,
(
Some((before_distance, before_translation)),
Some((after_distance, after_translation)),
) => {
if before_distance < after_distance {
before_translation
} else {
after_translation
}
}
}
}
}
/// Abstracts over [`FrameOffset`] and [`CharOffset`] so that we can use the same conversion
/// algorithm for both.
trait ParagraphOffset:
Add<usize, Output = Self> + Ord + Sub<Output = Self> + SaturatingSub + Copy + Sized
{
/// Gets the start offset of a [`SelectableTextRun`] for this offset/coordinate system.
fn start(run: &SelectableTextRun) -> Self;
fn zero() -> Self;
fn as_usize(self) -> usize;
}
impl ParagraphOffset for CharOffset {
fn start(run: &SelectableTextRun) -> Self {
run.content_start
}
fn zero() -> Self {
CharOffset::zero()
}
fn as_usize(self) -> usize {
CharOffset::as_usize(self)
}
}
impl ParagraphOffset for FrameOffset {
fn start(run: &SelectableTextRun) -> Self {
run.frame_start
}
fn zero() -> Self {
FrameOffset::zero()
}
fn as_usize(self) -> usize {
FrameOffset::as_usize(self)
}
}
@@ -0,0 +1,287 @@
use super::{OffsetMap, SelectableTextRun};
use crate::render::model::FrameOffset;
use string_offset::CharOffset;
#[test]
fn test_offset_map_basic() {
// Baseline test for a no-placeholder OffsetMap. The content_start is non-zero to mimic
// paragraphs within a code block.
let map = OffsetMap::new(vec![SelectableTextRun {
content_start: CharOffset::from(12),
frame_start: FrameOffset::zero(),
length: 10,
}]);
// The returned offset should be adjusted by the content start.
assert_eq!(map.to_content(FrameOffset::from(4)), 16.into());
// Mapping should clamp to run bounds.
assert_eq!(map.to_content(FrameOffset::from(12)), 22.into());
}
#[test]
fn test_offset_map_placeholders() {
// Set up an offset map for the following structure:
// |placeholder|text|placeholder|text|placeholder...
// Frame: 0 6 14 24 28
// Content: 0 1 9 10 14
let map = OffsetMap::new(vec![
SelectableTextRun {
// Even in the zero-state placeholder case, there's an empty content run just before it.
content_start: CharOffset::zero(),
frame_start: FrameOffset::zero(),
length: 0,
},
SelectableTextRun {
content_start: CharOffset::from(1),
frame_start: FrameOffset::from(6),
length: 8,
},
SelectableTextRun {
content_start: CharOffset::from(10),
frame_start: FrameOffset::from(24),
length: 4,
},
]);
// Depending on what they're closer to, characters at the start of the frame map to either
// the start of the line or the first content run.
assert_eq!(map.to_content(FrameOffset::from(2)), CharOffset::zero());
assert_eq!(map.to_content(FrameOffset::from(4)), CharOffset::from(1));
assert_eq!(map.to_frame(CharOffset::zero()), FrameOffset::zero());
assert_eq!(map.to_frame(CharOffset::from(1)), FrameOffset::from(6));
// Characters within the first text range map within the range.
assert_eq!(map.to_content(FrameOffset::from(7)), CharOffset::from(2));
assert_eq!(map.to_frame(CharOffset::from(2)), FrameOffset::from(7));
// Characters within the second placeholder map to the closer run.
assert_eq!(map.to_content(FrameOffset::from(16)), CharOffset::from(9));
assert_eq!(map.to_content(FrameOffset::from(20)), CharOffset::from(10));
assert_eq!(map.to_frame(CharOffset::from(9)), FrameOffset::from(14));
assert_eq!(map.to_frame(CharOffset::from(10)), FrameOffset::from(24));
// Characters in the last placeholder map to the end of the last text run.
assert_eq!(map.to_content(FrameOffset::from(28)), CharOffset::from(14));
assert_eq!(map.to_content(FrameOffset::from(50)), CharOffset::from(14));
assert_eq!(map.to_frame(CharOffset::from(14)), FrameOffset::from(28));
}
/// Walkthrough test to demonstrate how placeholders are represented in the [`OffsetMap`] and
/// [`TextFrame`].
///
/// This test only runs on macOS because it needs a text-layout implementation for [`EditDelta`]
/// that creates non-empty text frames.
#[test]
#[cfg(target_os = "macos")]
fn test_end_to_end() {
// Group imports here so they don't cause "unused import" warnings on other targets.
use warpui::{
App, color::ColorU, elements::Fill, fonts::Cache as FontCache, text_layout::LayoutCache,
};
use crate::{
content::{
buffer::{Buffer, BufferEditAction, EditOrigin},
selection_model::BufferSelectionModel,
text::IndentBehavior,
},
render::{
layout::TextLayout,
model::{
BlockItem, BrokenLinkStyle, CheckBoxStyle, HorizontalRuleStyle, InlineCodeStyle,
PARAGRAPH_MIN_HEIGHT, ParagraphStyles, RenderLayoutOptions, RichTextStyles,
TableStyle, test_utils::TEST_BASELINE_OFFSET,
},
},
};
App::test((), |mut app| async move {
let mut font_cache = FontCache::new(Box::new(warpui::platform::current::FontDB::new()));
let layout_cache = LayoutCache::new();
let paragraph_styles = ParagraphStyles {
font_family: font_cache
.load_system_font("Arial")
.expect("Arial must exist"),
font_size: 12.,
font_weight: Default::default(),
line_height_ratio: 1.2,
text_color: ColorU::white(),
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: None,
};
let inline_code = InlineCodeStyle {
font_family: font_cache
.load_system_font("Arial")
.expect("Arial must exist"),
background: ColorU::black(),
font_color: ColorU::white(),
};
let checkbox = CheckBoxStyle {
border_color: ColorU::white(),
border_width: 2.,
icon_path: "bundled/svg/check-thick.svg",
background: ColorU::black(),
hover_background: ColorU::black(),
};
let horizontal_rule = HorizontalRuleStyle {
rule_height: 2.,
color: ColorU::black(),
};
let broken_link = BrokenLinkStyle {
icon_path: "bundled/svg/link-broken-02.svg",
icon_color: ColorU::black(),
};
let styles = RichTextStyles {
base_text: paragraph_styles,
code_text: paragraph_styles,
embedding_text: paragraph_styles,
code_background: Default::default(),
embedding_background: Default::default(),
placeholder_color: ColorU::black(),
code_border: Default::default(),
selection_fill: Fill::None,
cursor_fill: Fill::None,
inline_code_style: inline_code,
check_box_style: checkbox,
horizontal_rule_style: horizontal_rule,
broken_link_style: broken_link,
block_spacings: Default::default(),
show_placeholder_text_on_empty_block: false,
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
cursor_width: 1.,
highlight_urls: true,
table_style: TableStyle {
border_color: ColorU::black(),
header_background: ColorU::black(),
cell_background: ColorU::black(),
alternate_row_background: None,
text_color: ColorU::white(),
header_text_color: ColorU::white(),
scrollbar_nonactive_thumb_color: ColorU::white(),
scrollbar_active_thumb_color: ColorU::white(),
font_family: paragraph_styles.font_family,
font_size: 12.,
cell_padding: 8.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
// Start by creating a buffer with a single line of text that includes a placeholder.
let buffer_handle = app.add_model(|_| Buffer::new(Box::new(|_, _| IndentBehavior::Ignore)));
let selection_handle = app.add_model(|_| BufferSelectionModel::new(buffer_handle.clone()));
buffer_handle.update(&mut app, |buffer, ctx| {
buffer.update_content(
BufferEditAction::Insert {
text: "HelloWorld",
style: Default::default(),
override_text_style: None,
},
EditOrigin::UserInitiated,
selection_handle.clone(),
ctx,
);
buffer.update_content(
BufferEditAction::InsertPlaceholder {
text: "test",
location: CharOffset::from(6),
},
EditOrigin::SystemEdit,
selection_handle.clone(),
ctx,
);
assert_eq!(
buffer.debug(),
"<text>Hello<placeholder_s>test<placeholder_e>World"
);
// The placeholder only counts as 1 character, so there are 11 buffer characters.
assert_eq!(buffer.max_charoffset(), 12.into());
});
// Now, lay out the buffer, which should produce a single `Paragraph` block.
let layout = app.read(|ctx| {
let delta = buffer_handle.as_ref(ctx).invalidate_layout();
let text_layout = TextLayout::new(
&layout_cache,
font_cache.text_layout_system(),
&styles,
1000.,
);
delta.layout_delta(
&text_layout,
None,
RenderLayoutOptions::default(),
None,
ctx,
)
});
let paragraph = match &layout.laid_out_line[..] {
[BlockItem::Paragraph(paragraph)] => paragraph,
other => panic!("Unexpected blocks: {other:?}"),
};
// The `TextFrame` includes each character we paint: "HellotestWorld".
let line = &paragraph.frame.lines()[0];
assert_eq!(
line.runs.iter().map(|run| run.glyphs.len()).sum::<usize>(),
14
);
assert_eq!(line.first_index(), 0); // The "H" glyph.
assert_eq!(line.last_index(), 13); // The "d" glyph.
// Because "test" is a placeholder, it creates a gap in the `OffsetMap`:
// - Characters 0-4 in the buffer map to characters 0-5 in the text frame ("Hello")
// - The character at buffer index 5 is the placeholder ("test"). It's not in the map, but
// is painted by characters 5-8 in the TextFrame
// - Characters 6-10 in the buffer map to characters 9-13 in the text frame ("World").
// Overall, it looks like this:
// Character: H e l l o | t e s t | W o r l d
// Buffer Index: 0 1 2 3 4 | 5 | 6 7 8 9 10
// TextFrame Index: 0 1 2 3 4 | 5 6 7 8 | 9 10 11 12 13
// In the OffsetMap representation, we only store the runs of non-placeholder characters,
// while placeholder characters form un-selectable "holes".
assert_eq!(
paragraph.offsets.runs,
vec![
// The run for "Hello":
SelectableTextRun {
content_start: 0.into(),
frame_start: 0.into(),
length: 5
},
// The run for "World":
SelectableTextRun {
content_start: 6.into(),
frame_start: 9.into(),
length: 5
}
]
);
// To go from a buffer character to a TextFrame character, we find the run that contains
// it - for offset `i`, this is the run where `run.content_start <= i < run.content_start + run.length`.
// Going from a TextFrame character to a buffer character is more complicated, because the
// character might belong to a placeholder. In that case, we find the two adjacent runs and
// pick the closest.
// Some examples:
// The "e" in "Hello":
assert_eq!(paragraph.offsets.to_frame(1.into()), 1.into());
assert_eq!(paragraph.offsets.to_content(1.into()), 1.into());
// The "s" in "test":
// Since it's in a placeholder, we can only use the placeholder's buffer char offset.
assert_eq!(paragraph.offsets.to_frame(5.into()), 5.into());
// When going the other direction, it's closer to World than Hello.
assert_eq!(paragraph.offsets.to_content(7.into()), 6.into());
// The "r" in "World": Note that the offsets don't map 1:1 because we have to account for
// the placeholder gap.
assert_eq!(paragraph.offsets.to_frame(8.into()), 11.into());
assert_eq!(paragraph.offsets.to_content(11.into()), 8.into());
});
}
@@ -0,0 +1,246 @@
//! Utilities for traversing laid-out blocks along with their positioning
//! information.
use std::sync::Arc;
use sum_tree::{Cursor, Dimension};
use warpui::{
geometry::vector::Vector2F,
text_layout::Line,
units::{IntoPixels, Pixels},
};
use crate::render::layout::line_height;
use string_offset::CharOffset;
use super::{
BlockItem, BlockSpacing, HorizontalRuleConfig, ImageBlockConfig, LaidOutEmbeddedItem,
LaidOutTable, LayoutSummary, LineCount, Paragraph, ParagraphBlock, RenderContext, bounds,
};
/// Wrapper to track an item's position, both in the buffer and on the screen.
#[derive(Debug)]
pub struct Positioned<'a, T> {
/// The starting character offset of this item, relative to the start of
/// the buffer.
pub start_char_offset: CharOffset,
/// The starting line number of this item.
pub start_line: LineCount,
/// The starting y-offset of this item, relative to the origin of the laid-out
/// content.
// TODO: There are at least 4 valid origins for pixel coordinates (content start,
// viewport start, paint origin, and block start). If that starts getting
// confusing, we might want to introduce wrappers similar to `DisplayPoint`
// and `SoftWrapPoint` in the input editor or `WithinBlock` and `WithinModel`
// in the terminal.
pub start_y_offset: Pixels,
pub style: BlockSpacing,
pub item: &'a T,
}
pub trait PositionedCursor<'a> {
/// The block at the current cursor position, along with its position.
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>>;
}
impl<'a, S: Dimension<'a, LayoutSummary>> PositionedCursor<'a>
for Cursor<'a, BlockItem, S, LayoutSummary>
{
fn positioned_item(&self) -> Option<Positioned<'a, BlockItem>> {
let item = self.item()?;
let summary = self.start();
Some(Positioned {
start_char_offset: summary.content_length,
start_y_offset: (summary.height as f32).into_pixels(),
start_line: summary.lines,
style: item.spacing(),
item,
})
}
}
impl<T> Positioned<'_, T> {
/// The origin of this item's content, relative to the start of the buffer.
pub fn content_origin(&self) -> Vector2F {
bounds::content_origin(self.start_y_offset, &self.style)
}
/// The visible origin of this item, relative to the start of the buffer.
pub fn visible_origin(&self) -> Vector2F {
bounds::visible_origin(self.start_y_offset, &self.style)
}
/// The origin of this item, relative to the start of the buffer, with no padding
/// or margin.
pub fn reserved_origin(&self) -> Vector2F {
bounds::reserved_origin(self.start_y_offset)
}
/// The origin of this item in rendering coordinates.
pub fn render_origin(&self, ctx: &RenderContext) -> Vector2F {
ctx.content_to_screen(self.reserved_origin())
}
}
/// Helpers specific to positioned [`Line`]s.
impl Positioned<'_, Line> {
pub fn end_y_offset(&self) -> Pixels {
self.start_y_offset + line_height(self.item).into_pixels() + self.style.top_offset()
}
}
/// Helpers specific to a positioned [`BlockItem`].
impl<'a> Positioned<'a, BlockItem> {
/// The ending character offset of this item (exclusive).
pub fn end_char_offset(&self) -> CharOffset {
self.start_char_offset + self.item.content_length()
}
/// Check if this block item contains a content offset.
pub fn contains_content(&self, offset: CharOffset) -> bool {
self.start_char_offset <= offset && self.end_char_offset() > offset
}
/// The ending line number of this item (exclusive).
pub fn end_line(&self) -> LineCount {
self.start_line + self.item.lines()
}
/// Position this item's code block.
pub fn code_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::RunnableCodeBlock { .. }),
"Must be a runnable code block"
);
self.position(block)
}
pub fn temporary_block(&self, block: &'a ParagraphBlock) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::TemporaryBlock { .. }),
"Must be a temporary block"
);
self.position(block)
}
pub fn embedded(
&self,
embedded_item: &'a Arc<dyn LaidOutEmbeddedItem>,
) -> Positioned<'a, Arc<dyn LaidOutEmbeddedItem>> {
debug_assert!(
matches!(self.item, BlockItem::Embedded(_)),
"Must be an embedded object"
);
self.position(embedded_item)
}
pub fn task_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::TaskList { .. }),
"Must be a task list block"
);
self.position(paragraph)
}
pub fn unordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::UnorderedList { .. }),
"Must be an unordered list block"
);
self.position(paragraph)
}
/// Position the content paragraph for an ordered list item.
pub fn ordered_list(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::OrderedList { .. }),
"Must be an ordered list block"
);
self.position(paragraph)
}
pub fn header(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::Header { .. }),
"Must be a header block"
);
self.position_centered(paragraph, paragraph.height())
}
/// Position this item's paragraph.
pub fn paragraph(&self, paragraph: &'a Paragraph) -> Positioned<'a, Paragraph> {
debug_assert!(
matches!(self.item, BlockItem::Paragraph(_)),
"Must be a paragraph"
);
// Short paragraphs may have extra padding to meet the minimum paragraph height.
self.position_centered(paragraph, paragraph.height())
}
pub fn text_block(
&self,
paragraph_block: &'a ParagraphBlock,
) -> Positioned<'a, ParagraphBlock> {
debug_assert!(
matches!(self.item, BlockItem::TextBlock { .. }),
"Must be a text block"
);
self.position(paragraph_block)
}
/// Position the trailing newline cursor.
pub fn trailing_newline(&self, cursor: &'a super::Cursor) -> Positioned<'a, super::Cursor> {
// Match the spacing behavior of single-line paragraphs.
self.position_centered(cursor, cursor.height)
}
pub fn horizontal_rule(
&self,
horizontal_rule: &'a HorizontalRuleConfig,
) -> Positioned<'a, HorizontalRuleConfig> {
self.position_centered(horizontal_rule, horizontal_rule.line_height)
}
pub fn image(&self, image_config: &'a ImageBlockConfig) -> Positioned<'a, ImageBlockConfig> {
self.position_centered(image_config, image_config.height)
}
pub fn table(&self, laid_out_table: &'a LaidOutTable) -> Positioned<'a, LaidOutTable> {
debug_assert!(
matches!(self.item, BlockItem::Table(_)),
"Must be a table block"
);
self.position_centered(laid_out_table, laid_out_table.height())
}
/// Helper to position-wrap an item's contents when pattern-matching.
///
/// ```ignore
/// match positioned.item {
/// BlockItem::Paragraph(paragraph) => positioned.position(paragraph),
/// }
/// ```
fn position<T>(&self, content: &'a T) -> Positioned<'a, T> {
Positioned {
start_char_offset: self.start_char_offset,
start_y_offset: self.start_y_offset,
start_line: self.start_line,
style: self.style,
item: content,
}
}
/// Like `positioned`, but centers the content based on the block's expected content height.
/// Use this for blocks with a minimum height.
fn position_centered<T>(
&self,
content: &'a T,
actual_content_height: Pixels,
) -> Positioned<'a, T> {
let mut positioned = self.position(content);
let gap = self.item.content_height() - actual_content_height;
positioned.start_y_offset += gap / 2.0.into_pixels();
positioned
}
}
@@ -0,0 +1,32 @@
use warpui::EntityId;
/// Utility for consistently creating and referencing saved position IDs for
/// rich text.
#[derive(Debug, Clone)]
pub struct SavedPositions {
/// Entity ID for the [`super::RenderState`] that owns these positions. This
/// disambiguates IDs across multiple rich text editors.
model_id: EntityId,
}
impl SavedPositions {
/// Create a new `SavedPositions` given the parent model ID.
pub(super) fn new(model_id: EntityId) -> Self {
Self { model_id }
}
/// Saved position ID for the cursor location.
pub fn cursor_id(&self) -> String {
format!("warp_editor:cursor_{}", self.model_id)
}
/// The bounding box for the text selection.
pub fn text_selection_id(&self) -> String {
format!("warp_editor:text_selection_{}", self.model_id)
}
/// The first line of the block that the mouse is currently hovered over.
pub fn hovered_block_start(&self) -> String {
format!("warp_editor:hovered_block_start_{}", self.model_id)
}
}
@@ -0,0 +1,424 @@
use markdown_parser::FormattedTextFragment;
use string_offset::CharOffset;
/// Maps between linear CharOffset positions and table cell coordinates.
///
/// The table content is represented as:
/// ```text
/// Header1\tHeader2\tHeader3\n
/// Cell1\tCell2\tCell3\n
/// Cell4\tCell5\tCell6\n
/// ```
///
/// This structure enables:
/// - Finding which cell contains a given CharOffset
/// - Getting the CharOffset range for a specific cell
/// - Determining if an offset is on a separator (tab or newline)
#[derive(Debug, Clone)]
pub struct TableOffsetMap {
cell_ranges: Vec<CellRange>,
row_ranges: Vec<RowRange>,
cell_index_by_row_col: Vec<Vec<usize>>,
total_length: CharOffset,
num_rows: usize,
num_cols: usize,
}
#[derive(Debug, Clone, Copy)]
struct RowRange {
start: CharOffset,
end: CharOffset,
}
/// A range representing a single cell's position in the linear character stream.
#[derive(Debug, Clone, Copy)]
pub struct CellRange {
pub start: CharOffset,
pub end: CharOffset,
pub row: usize,
pub col: usize,
}
/// The location of a character offset within a table cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellAtOffset {
pub row: usize,
pub col: usize,
pub offset_in_cell: CharOffset,
}
/// The character offset range (start, end) of a cell in the linear content stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellOffsetRange {
pub start: CharOffset,
pub end: CharOffset,
}
/// Result of looking up a CharOffset in the table.
#[derive(Debug, Clone, Copy)]
pub enum TablePosition {
/// Offset is within a cell's text content
InCell {
row: usize,
col: usize,
offset_in_cell: CharOffset,
},
/// Offset is on a tab separator between cells
OnTab { row: usize, after_col: usize },
/// Offset is on a newline at the end of a row
OnNewline { row: usize },
}
#[derive(Debug, Clone)]
pub struct TableCellOffsetMap {
fragment_ranges: Vec<TableCellFragmentRange>,
rendered_length: CharOffset,
source_length: CharOffset,
}
#[derive(Debug, Clone, Copy)]
struct TableCellFragmentRange {
rendered_start: CharOffset,
rendered_end: CharOffset,
source_end: CharOffset,
visible_source_start: CharOffset,
visible_source_end: CharOffset,
}
impl TableOffsetMap {
/// Build a new TableOffsetMap from cell text lengths.
///
/// `cell_lengths` is a 2D array where cell_lengths[row][col] is the character
/// count of that cell's text content.
pub fn new(cell_lengths: Vec<Vec<usize>>) -> Self {
let num_rows = cell_lengths.len();
let num_cols = cell_lengths.first().map(|r| r.len()).unwrap_or(0);
let mut cell_ranges = Vec::new();
let mut row_ranges = Vec::with_capacity(num_rows);
let mut cell_index_by_row_col = Vec::with_capacity(num_rows);
let mut current_offset = CharOffset::zero();
for (row_idx, row) in cell_lengths.iter().enumerate() {
let row_start = current_offset;
let mut row_cell_indices = Vec::with_capacity(row.len());
for (col_idx, &cell_len) in row.iter().enumerate() {
let start = current_offset;
let end = start + cell_len;
let cell_idx = cell_ranges.len();
cell_ranges.push(CellRange {
start,
end,
row: row_idx,
col: col_idx,
});
row_cell_indices.push(cell_idx);
current_offset = end;
if col_idx < row.len() - 1 {
current_offset += 1;
}
}
current_offset += 1;
row_ranges.push(RowRange {
start: row_start,
end: current_offset,
});
cell_index_by_row_col.push(row_cell_indices);
}
Self {
cell_ranges,
row_ranges,
cell_index_by_row_col,
total_length: current_offset,
num_rows,
num_cols,
}
}
/// Get the total content length including all cells, tabs, and newlines.
pub fn total_length(&self) -> CharOffset {
self.total_length
}
/// Find what's at the given offset.
pub fn position_at_offset(&self, offset: CharOffset) -> Option<TablePosition> {
if offset >= self.total_length {
return None;
}
let row_idx = self
.row_ranges
.partition_point(|row_range| row_range.end <= offset);
let row_range = self.row_ranges.get(row_idx)?;
if offset < row_range.start {
return None;
}
let row_cells = self.cell_index_by_row_col.get(row_idx)?;
let mut previous_cell: Option<CellRange> = None;
for &cell_idx in row_cells {
let cell = *self.cell_ranges.get(cell_idx)?;
if offset < cell.start {
return previous_cell.map(|cell| self.separator_position(cell));
}
if offset < cell.end {
return Some(TablePosition::InCell {
row: cell.row,
col: cell.col,
offset_in_cell: offset - cell.start,
});
}
if offset == cell.end {
return Some(self.separator_position(cell));
}
previous_cell = Some(cell);
}
Some(TablePosition::OnNewline { row: row_idx })
}
/// Find which cell contains the given offset.
/// If the offset is on a separator, returns the cell before the separator.
pub fn cell_at_offset(&self, offset: CharOffset) -> Option<CellAtOffset> {
match self.position_at_offset(offset)? {
TablePosition::InCell {
row,
col,
offset_in_cell,
} => Some(CellAtOffset {
row,
col,
offset_in_cell,
}),
TablePosition::OnTab { row, after_col } => {
let cell = self.cell_range(row, after_col)?;
Some(CellAtOffset {
row,
col: after_col,
offset_in_cell: cell.end - cell.start,
})
}
TablePosition::OnNewline { row } => {
let last_col = self
.cell_index_by_row_col
.get(row)
.map(|cells| cells.len())
.unwrap_or(0)
.saturating_sub(1);
let cell = self.cell_range(row, last_col)?;
Some(CellAtOffset {
row,
col: last_col,
offset_in_cell: cell.end - cell.start,
})
}
}
}
/// Get the character offset range for a specific cell by (row, col).
pub fn cell_range(&self, row: usize, col: usize) -> Option<CellOffsetRange> {
let cell_idx = *self.cell_index_by_row_col.get(row)?.get(col)?;
let cell = self.cell_ranges.get(cell_idx)?;
Some(CellOffsetRange {
start: cell.start,
end: cell.end,
})
}
/// Check if an offset is on a tab or newline separator.
pub fn is_separator(&self, offset: CharOffset) -> bool {
matches!(
self.position_at_offset(offset),
Some(TablePosition::OnTab { .. } | TablePosition::OnNewline { .. })
)
}
/// Get all cells that intersect with the given offset range.
/// Returns cells in row-major order.
pub fn cells_in_range(&self, start: CharOffset, end: CharOffset) -> Vec<CellRange> {
self.cell_ranges
.iter()
.filter(|cell| cell.end > start && cell.start < end)
.copied()
.collect()
}
/// Get the number of rows in the table.
pub fn num_rows(&self) -> usize {
self.num_rows
}
/// Get the number of columns in the table.
pub fn num_cols(&self) -> usize {
self.num_cols
}
fn separator_position(&self, cell: CellRange) -> TablePosition {
let row_len = self
.cell_index_by_row_col
.get(cell.row)
.map(|cells| cells.len())
.unwrap_or(0);
if cell.col + 1 < row_len {
TablePosition::OnTab {
row: cell.row,
after_col: cell.col,
}
} else {
TablePosition::OnNewline { row: cell.row }
}
}
}
// TODO: When we add editable tables or other complex table operations, consider moving
// cell/row boundaries into the `SumTree` with new `BufferText` marker types so that
// per-cell offsets can be derived by seeking to boundaries instead of re-parsing the
// whole table on every edit. The current embedded-text-plus-cached-parse model is
// sufficient for read-only tables; see PR #24326 discussion for context.
impl TableCellOffsetMap {
/// Build a cell offset map from the raw cell `source` text and the parsed `inline`
/// fragments produced by the Markdown parser.
///
/// This walks `source` character-by-character alongside the rendered text of each fragment,
/// which makes it robust to:
/// - backslash escapes (e.g. `\*foo` consumes two source chars for one rendered char),
/// - changes to Markdown marker syntax (we don't hardcode `**`, `*`, `<u>`, etc. here), and
/// - nested styles where adjacent fragments share outer markers (e.g. `**a *b* c**`), since we
/// attribute each marker to the fragment whose rendered text follows it.
pub fn from_inline_and_source(source: &str, inline: &[FormattedTextFragment]) -> Self {
let source_chars: Vec<char> = source.chars().collect();
let total_source_chars = source_chars.len();
let mut fragment_ranges: Vec<TableCellFragmentRange> = Vec::new();
let mut rendered_offset = CharOffset::zero();
let mut source_idx: usize = 0;
for fragment in inline {
let rendered_chars: Vec<char> = fragment.text.chars().collect();
if rendered_chars.is_empty() {
continue;
}
let first_rendered = rendered_chars[0];
while source_idx < total_source_chars {
let sc = source_chars[source_idx];
if sc == '\\'
&& source_idx + 1 < total_source_chars
&& source_chars[source_idx + 1] == first_rendered
{
source_idx += 1;
break;
}
if sc == first_rendered {
break;
}
source_idx += 1;
}
let visible_source_start = CharOffset::from(source_idx);
for &rendered_char in &rendered_chars {
if source_idx >= total_source_chars {
break;
}
let sc = source_chars[source_idx];
if sc == '\\'
&& source_idx + 1 < total_source_chars
&& source_chars[source_idx + 1] == rendered_char
{
source_idx += 2;
} else {
source_idx += 1;
}
}
let visible_source_end = CharOffset::from(source_idx);
let rendered_start = rendered_offset;
let rendered_end = rendered_start + CharOffset::from(rendered_chars.len());
fragment_ranges.push(TableCellFragmentRange {
rendered_start,
rendered_end,
source_end: visible_source_end,
visible_source_start,
visible_source_end,
});
rendered_offset = rendered_end;
}
let total_source_offset = CharOffset::from(total_source_chars);
let fragment_count = fragment_ranges.len();
for i in 0..fragment_count {
let next_start = if i + 1 < fragment_count {
fragment_ranges[i + 1].visible_source_start
} else {
total_source_offset
};
fragment_ranges[i].source_end = next_start;
}
Self {
fragment_ranges,
rendered_length: rendered_offset,
source_length: total_source_offset,
}
}
pub fn rendered_length(&self) -> CharOffset {
self.rendered_length
}
pub fn source_length(&self) -> CharOffset {
self.source_length
}
pub fn rendered_to_source(&self, rendered_offset: CharOffset) -> CharOffset {
if rendered_offset >= self.rendered_length {
return self
.fragment_ranges
.last()
.map(|fragment| fragment.visible_source_end)
.unwrap_or(self.source_length);
}
for fragment in &self.fragment_ranges {
if rendered_offset < fragment.rendered_end {
return fragment.visible_source_start + (rendered_offset - fragment.rendered_start);
}
}
self.source_length
}
pub fn source_to_rendered(&self, source_offset: CharOffset) -> CharOffset {
if source_offset >= self.source_length {
return self.rendered_length;
}
for fragment in &self.fragment_ranges {
if source_offset < fragment.source_end {
if source_offset <= fragment.visible_source_start {
return fragment.rendered_start;
}
if source_offset >= fragment.visible_source_end {
return fragment.rendered_end;
}
return fragment.rendered_start + (source_offset - fragment.visible_source_start);
}
}
self.rendered_length
}
}
#[cfg(test)]
#[path = "table_offset_map_tests.rs"]
mod tests;
@@ -0,0 +1,261 @@
use super::*;
use markdown_parser::Hyperlink;
use markdown_parser::parse_inline_markdown;
use markdown_parser::weight::CustomWeight;
#[test]
fn test_simple_table() {
let map = TableOffsetMap::new(vec![vec![1, 2], vec![3, 1]]);
assert_eq!(map.total_length(), CharOffset::from(11));
assert_eq!(map.num_rows(), 2);
assert_eq!(map.num_cols(), 2);
assert!(matches!(
map.position_at_offset(CharOffset::from(0)),
Some(TablePosition::InCell { row: 0, col: 0, .. })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(1)),
Some(TablePosition::OnTab {
row: 0,
after_col: 0
})
));
assert!(matches!(
map.position_at_offset(CharOffset::from(2)),
Some(TablePosition::InCell { row: 0, col: 1, .. })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(4)),
Some(TablePosition::OnNewline { row: 0 })
));
assert!(matches!(
map.position_at_offset(CharOffset::from(5)),
Some(TablePosition::InCell { row: 1, col: 0, .. })
));
}
#[test]
fn test_cell_at_offset() {
let map = TableOffsetMap::new(vec![vec![3, 3]]);
assert_eq!(
map.cell_at_offset(CharOffset::from(0)),
Some(CellAtOffset {
row: 0,
col: 0,
offset_in_cell: CharOffset::from(0)
})
);
assert_eq!(
map.cell_at_offset(CharOffset::from(2)),
Some(CellAtOffset {
row: 0,
col: 0,
offset_in_cell: CharOffset::from(2)
})
);
assert_eq!(
map.cell_at_offset(CharOffset::from(4)),
Some(CellAtOffset {
row: 0,
col: 1,
offset_in_cell: CharOffset::from(0)
})
);
}
#[test]
fn test_out_of_bounds_offset() {
let map = TableOffsetMap::new(vec![vec![2, 2]]);
assert!(map.position_at_offset(map.total_length()).is_none());
assert!(map.position_at_offset(CharOffset::from(100)).is_none());
assert!(map.cell_at_offset(map.total_length()).is_none());
}
#[test]
fn test_is_separator() {
let map = TableOffsetMap::new(vec![vec![1, 1], vec![1, 1]]);
assert!(!map.is_separator(CharOffset::from(0)));
assert!(map.is_separator(CharOffset::from(1)));
assert!(map.is_separator(CharOffset::from(3)));
assert!(!map.is_separator(CharOffset::from(4)));
}
#[test]
fn test_empty_cells() {
let map = TableOffsetMap::new(vec![vec![0, 3], vec![2, 0]]);
assert_eq!(map.num_rows(), 2);
assert_eq!(map.num_cols(), 2);
assert!(matches!(
map.position_at_offset(CharOffset::from(0)),
Some(TablePosition::OnTab {
row: 0,
after_col: 0
})
));
assert!(matches!(
map.position_at_offset(CharOffset::from(1)),
Some(TablePosition::InCell { row: 0, col: 1, .. })
));
}
#[test]
fn test_cells_in_range() {
let map = TableOffsetMap::new(vec![vec![2, 2], vec![2, 2]]);
let cells = map.cells_in_range(CharOffset::from(0), map.total_length());
assert_eq!(cells.len(), 4);
let first_row = map.cells_in_range(CharOffset::from(0), CharOffset::from(5));
assert_eq!(first_row.len(), 2);
assert_eq!(first_row[0].row, 0);
assert_eq!(first_row[0].col, 0);
assert_eq!(first_row[1].row, 0);
assert_eq!(first_row[1].col, 1);
}
#[test]
fn test_cell_range() {
let map = TableOffsetMap::new(vec![vec![3, 2]]);
assert_eq!(
map.cell_range(0, 0),
Some(CellOffsetRange {
start: CharOffset::from(0),
end: CharOffset::from(3)
})
);
assert_eq!(
map.cell_range(0, 1),
Some(CellOffsetRange {
start: CharOffset::from(4),
end: CharOffset::from(6)
})
);
assert!(map.cell_range(0, 2).is_none());
assert!(map.cell_range(1, 0).is_none());
}
#[test]
fn test_table_cell_offset_map_handles_bold_and_links() {
let source = "**Bold** [Link](https://warp.dev)";
let inline = parse_inline_markdown(source);
assert!(
inline.iter().any(|fragment| fragment
.styles
.weight
.is_some_and(|weight| matches!(weight, CustomWeight::Bold))),
"parsed inline should have a bold fragment"
);
assert!(
inline
.iter()
.any(|fragment| matches!(&fragment.styles.hyperlink, Some(Hyperlink::Url(url)) if url == "https://warp.dev")),
"parsed inline should have a hyperlink fragment"
);
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(map.rendered_length(), CharOffset::from(9));
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_to_source(CharOffset::from(0)),
CharOffset::from(2)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(4)),
CharOffset::from(8)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(5)),
CharOffset::from(10)
);
assert_eq!(
map.rendered_to_source(CharOffset::from(9)),
CharOffset::from(14)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(0)),
CharOffset::from(0)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(2)),
CharOffset::from(0)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(11)),
CharOffset::from(6)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(14)),
CharOffset::from(9)
);
assert_eq!(
map.source_to_rendered(CharOffset::from(32)),
CharOffset::from(9)
);
}
#[test]
fn test_table_cell_offset_map_handles_backslash_escaped_punctuation() {
let source = "a \\*star\\* b";
let inline = parse_inline_markdown(source);
let rendered_text: String = inline
.iter()
.map(|fragment| fragment.text.as_str())
.collect();
assert_eq!(rendered_text, "a *star* b");
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(
map.rendered_length(),
CharOffset::from(rendered_text.chars().count())
);
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_to_source(CharOffset::from(rendered_text.chars().count())),
CharOffset::from(source.chars().count())
);
}
#[test]
fn test_table_cell_offset_map_handles_nested_styles() {
let source = "**a *b* c**";
let inline = parse_inline_markdown(source);
let rendered_text: String = inline
.iter()
.map(|fragment| fragment.text.as_str())
.collect();
assert_eq!(rendered_text, "a b c");
let map = TableCellOffsetMap::from_inline_and_source(source, &inline);
assert_eq!(
map.source_length(),
CharOffset::from(source.chars().count())
);
assert_eq!(
map.rendered_length(),
CharOffset::from(rendered_text.chars().count())
);
for (rendered_idx, rendered_char) in rendered_text.chars().enumerate() {
let source_pos = map.rendered_to_source(CharOffset::from(rendered_idx));
assert_eq!(
source.chars().nth(source_pos.as_usize()),
Some(rendered_char),
"rendered {rendered_idx} ({rendered_char:?}) should map to same char in source",
);
}
}
@@ -0,0 +1,323 @@
//! Test helpers for all render model tests.
use parking_lot::Once;
use std::{mem, sync::Arc};
use vec1::{Vec1, vec1};
use crate::content::text::BufferBlockStyle;
use ordered_float::OrderedFloat;
use warpui::elements::ListIndentLevel;
use warpui::{
color::ColorU,
elements::{Border, Fill},
fonts::{FamilyId, Weight},
geometry::vector::vec2f,
text_layout::{CaretPosition, Glyph, Line, Run, TextFrame},
units::{IntoPixels, Pixels},
};
use super::{
BlockItem, BrokenLinkStyle, CheckBoxStyle, DEFAULT_BLOCK_SPACINGS, HorizontalRuleStyle,
InlineCodeStyle, OffsetMap, PARAGRAPH_MIN_HEIGHT, Paragraph, ParagraphStyles, RichTextStyles,
TEXT_SPACING, TableStyle,
};
pub const TEST_BASELINE_OFFSET: f32 = 0.7;
/// Create a new paragraph that occupies the given space but has no content.
pub fn mock_paragraph(height: f32, width: f32, content_length: usize) -> BlockItem {
let frame = TextFrame::new(
vec1![Line {
width,
trailing_whitespace_width: 0.,
runs: Vec::new(),
// The line's effective height is determined by its font size and line height
// ratio, so set those to produce the expected height,
font_size: height,
line_height_ratio: 1.,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: height * TEST_BASELINE_OFFSET,
descent: height * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: Vec::new(),
chars_with_missing_glyphs: Vec::new(),
}],
width,
Default::default(),
);
BlockItem::paragraph(
Arc::new(frame),
OffsetMap::direct(content_length),
content_length.into(),
TEXT_SPACING,
Some(PARAGRAPH_MIN_HEIGHT),
)
}
/// Create a new paragraph block laid out via [`layout`].
pub fn laid_out_paragraph(
text: &str,
styles: &RichTextStyles,
max_width: impl IntoPixels,
) -> BlockItem {
BlockItem::Paragraph(layout_paragraph(
text,
styles,
&BufferBlockStyle::PlainText,
max_width,
))
}
/// Create a new paragraph block laid out via [`layout`].
pub fn laid_out_unordered_lists(
text: &str,
styles: &RichTextStyles,
max_width: impl IntoPixels,
) -> BlockItem {
BlockItem::UnorderedList {
indent_level: ListIndentLevel::One,
paragraph: layout_paragraph(
text,
styles,
&BufferBlockStyle::UnorderedList {
indent_level: ListIndentLevel::One,
},
max_width,
),
}
}
/// Lays out a single paragraph of text.
pub fn layout_paragraph(
text: &str,
styles: &RichTextStyles,
block_style: &BufferBlockStyle,
max_width: impl IntoPixels,
) -> Paragraph {
let Some(text) = text.strip_suffix('\n') else {
panic!("Laid out paragraph should end with newline");
};
let content_length = text.chars().count() + 1; // Add back 1 for the newline.
Paragraph::new(
Arc::new(layout(text, styles, max_width)),
OffsetMap::direct(content_length),
content_length.into(),
vec![],
styles.block_spacings.from_block_style(block_style),
Some(PARAGRAPH_MIN_HEIGHT),
)
}
/// Lays out each hard-wrapped paragraph in `text`
pub fn layout_paragraphs(
text: &str,
styles: &RichTextStyles,
block_style: &BufferBlockStyle,
max_width: impl IntoPixels + Copy,
) -> Vec1<Paragraph> {
Vec1::try_from_vec(
text.split('\n')
.map(|line| {
let frame = Arc::new(layout(line, styles, max_width));
let content_length = line.chars().count() + 1; // Add back 1 for the newline.
Paragraph::new(
frame,
OffsetMap::direct(content_length),
content_length.into(),
vec![],
styles.block_spacings.from_block_style(block_style),
Some(PARAGRAPH_MIN_HEIGHT),
)
})
.collect::<Vec<_>>(),
)
.expect("Should have at least one paragraph")
}
/// Static default color, since [`ColorU`] constructors aren't `const`.
const WHITE: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
pub const TEST_STYLES: RichTextStyles = RichTextStyles {
base_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: None,
},
code_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: Some(4),
},
code_background: Fill::None,
embedding_background: Fill::None,
embedding_text: ParagraphStyles {
font_family: FamilyId(0),
font_size: 10.,
font_weight: Weight::Normal,
line_height_ratio: 1.,
text_color: WHITE,
baseline_ratio: TEST_BASELINE_OFFSET,
fixed_width_tab_size: Some(4),
},
code_border: Border::new(0.),
placeholder_color: WHITE,
selection_fill: Fill::None,
cursor_fill: Fill::None,
inline_code_style: InlineCodeStyle {
font_family: FamilyId(0),
background: WHITE,
font_color: WHITE,
},
check_box_style: CheckBoxStyle {
border_width: 2.,
border_color: WHITE,
icon_path: "bundled/svg/check-thick.svg",
background: WHITE,
hover_background: WHITE,
},
horizontal_rule_style: HorizontalRuleStyle {
rule_height: 2.,
color: WHITE,
},
broken_link_style: BrokenLinkStyle {
icon_path: "bundled/svg/link-broken-02.svg",
icon_color: WHITE,
},
block_spacings: DEFAULT_BLOCK_SPACINGS,
show_placeholder_text_on_empty_block: false,
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
cursor_width: 1.,
highlight_urls: true,
table_style: TableStyle {
border_color: WHITE,
header_background: WHITE,
cell_background: WHITE,
alternate_row_background: None,
text_color: WHITE,
header_text_color: WHITE,
scrollbar_nonactive_thumb_color: WHITE,
scrollbar_active_thumb_color: WHITE,
font_family: FamilyId(0),
font_size: 10.,
cell_padding: 8.0,
outer_border: true,
column_dividers: true,
row_dividers: true,
},
};
/// Minimal text layout for unit tests that require populated text frames.
///
/// This implementation soft-wraps at `max_width`, but without any segmentation
/// rules.
pub fn layout(text: &str, styles: &RichTextStyles, max_width: impl IntoPixels) -> TextFrame {
let max_width = max_width.into_pixels();
// For simplicity, pretend characters are square.
let char_width = styles.base_text.font_size.into_pixels();
let mut lines_acc = vec![];
let mut glyphs_acc = vec![];
let mut carets_acc = vec![];
let mut line_width = Pixels::zero();
for (index, ch) in text.chars().enumerate() {
assert_ne!(ch, '\n', "Hard breaks not supported");
if line_width + char_width > max_width {
lines_acc.push(Line {
width: line_width.as_f32(),
trailing_whitespace_width: 0.,
runs: vec![Run {
font_id: warpui::fonts::FontId(0),
styles: Default::default(),
glyphs: mem::take(&mut glyphs_acc),
width: line_width.as_f32(),
}],
font_size: styles.base_text.font_size,
line_height_ratio: styles.base_text.line_height_ratio,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: mem::take(&mut carets_acc),
chars_with_missing_glyphs: Vec::new(),
});
line_width = Pixels::zero();
}
glyphs_acc.push(Glyph {
id: 0,
position_along_baseline: vec2f(line_width.as_f32(), 0.),
index,
width: char_width.as_f32(),
});
carets_acc.push(CaretPosition {
position_in_line: line_width.as_f32(),
start_offset: index,
last_offset: index,
});
line_width += char_width;
}
// Push any remaining characters (or an empty line, if the text frame was empty).
if !glyphs_acc.is_empty() || lines_acc.is_empty() {
lines_acc.push(Line {
width: line_width.as_f32(),
trailing_whitespace_width: 0.,
runs: vec![Run {
font_id: warpui::fonts::FontId(0),
styles: Default::default(),
glyphs: glyphs_acc,
width: line_width.as_f32(),
}],
font_size: styles.base_text.font_size,
line_height_ratio: styles.base_text.line_height_ratio,
baseline_ratio: TEST_BASELINE_OFFSET,
ascent: styles.base_text.font_size * TEST_BASELINE_OFFSET,
descent: styles.base_text.font_size * (1. - TEST_BASELINE_OFFSET),
clip_config: None,
caret_positions: carets_acc,
chars_with_missing_glyphs: Vec::new(),
});
}
let max_width = lines_acc
.iter()
.map(|line| OrderedFloat(line.width))
.max()
.unwrap_or_default();
match Vec1::try_from_vec(lines_acc) {
Ok(lines) => TextFrame::new(lines, max_width.into_inner(), Default::default()),
Err(_) => TextFrame::empty(
styles.base_text.font_size,
styles.base_text.line_height_ratio,
),
}
}
/// Initialize logging for tests. This should be called at the start of any test that needs logging.
pub fn init_logging() {
// If multiple tests run in the same process, we should still only set up logging once.
static INIT: Once = Once::new();
INIT.call_once(|| {
env_logger::builder()
.parse_filters("warp_editor=trace")
.is_test(true)
.init();
});
}
+444
View File
@@ -0,0 +1,444 @@
use float_cmp::ApproxEq;
use sum_tree::{SeekBias, SumTree};
use warpui::{
SizeConstraint,
geometry::{
rect::RectF,
vector::{Vector2F, vec2f},
},
units::{IntoPixels, Pixels},
};
use crate::render::element::RenderContext;
use string_offset::CharOffset;
use super::{
AUTO_SCROLL_MARGIN, BlockItem, BlockSpacing, Height, HitTestOptions, LayoutSummary, Location,
RenderState, UNIT_MARGIN, bounds, positioned::PositionedCursor,
};
/// For horizontal autoscrolling, it is very easy to "stuck" on a character if it is aligned exactly on the viewport boundary.
/// To help make scrolling more smooth, add a small margin here to overcome these boundaries.
const HORIZONTAL_SCROLL_MARGIN: f32 = 4.;
#[cfg(test)]
#[path = "viewport_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportState {
/// Width of the viewport. Currently, we soft-wrap text to fit this. However,
/// we'll eventually support horizontal scrolling if the viewport is narrower
/// than some minimum content width.
width: Pixels,
/// Height of the viewport. All scrolling and viewporting is in terms of
/// pixels, not lines, as the line height varies for different content.
height: Pixels,
/// Vertical scrolling offset. This is the distance from the start of the
/// content (height 0) to the first visible content.
scroll_top: Pixels,
/// Horizontal scrolling offset.
scroll_left: Pixels,
}
/// A visible, viewported item. This stores all the information needed to lay out and display a
/// block and any associated UI controls in the current viewport.
///
/// Because the viewport item is needed throughout the `Element` lifecycle, it does not directly
/// reference the rendering model. Instead, it holds offsets that refer back to the model, relying
/// on the UI framework to guarantee that the model does not change without a re-render.
#[derive(Debug)]
pub struct ViewportItem {
/// The y-offset to display this item at, relative to the viewport origin.
/// If this is negative, the item is partially above the viewport.
pub viewport_offset: Pixels,
/// The y-offset of this item, relative to the content origin.
pub content_offset: Pixels,
/// The size of this item's content, in pixels.
pub content_size: Vector2F,
/// Spacing around this item.
pub spacing: BlockSpacing,
/// Offset of the start of the block backing this item.
pub block_offset: CharOffset,
}
/// A snapshot of the scroll position. This may only be used to scroll back to the original
/// position, and cannot be inspected.
#[derive(Clone, Copy, Debug)]
pub struct ScrollPositionSnapshot {
/// The offset of the top left character in the viewport. We use this to represent the scroll
/// position, rather than a line count, to be resilient to soft-wrapping changes. If the
/// viewport is resized, then the content that a given line offset refers to will likely be
/// different.
first_character_offset: CharOffset,
}
impl ScrollPositionSnapshot {
/// Map this snapshot back to a `scroll_top` offset for the current render state.
pub(super) fn to_scroll_top(self, render_state: &RenderState) -> Pixels {
render_state
.character_bounds(self.first_character_offset)
.map_or(Pixels::zero(), |bounds| bounds.min_y().into_pixels())
}
/// Snapshot the render state's current scroll position.
pub(super) fn from_scroll_top(render_state: &RenderState) -> Self {
let first_character_offset = match render_state.viewport_coordinates_to_location(
Pixels::zero(),
Pixels::zero(),
&HitTestOptions {
force_text_selection: true,
},
) {
Location::Text { char_offset, .. } => char_offset,
Location::Block { start_offset, .. } => start_offset,
};
Self {
first_character_offset,
}
}
#[cfg(test)]
pub fn first_character_offset(self) -> CharOffset {
self.first_character_offset
}
}
pub struct ViewportIterator<'a> {
cursor: sum_tree::Cursor<'a, BlockItem, Height, LayoutSummary>,
/// The starting y-offset of content to display.
content_start: Pixels,
/// The ending y-offset of content to display (exclusive). This may be past
/// the end of the document, but it just needs to be an upper bound.
content_end: Pixels,
/// Maximum width the painted object could take in the current viewport.
max_width: Pixels,
}
#[derive(Debug, Clone, Copy)]
pub struct SizeInfo {
/// The size of the viewport, in pixels.
pub viewport_size: Vector2F,
/// Whether or not text must be laid out again to fit the new viewport size.
pub needs_layout: bool,
}
impl ViewportState {
/// Create a new `ViewportState` with the given viewport size, scrolled to
/// the top of the document.
pub fn new(width: Pixels, height: Pixels) -> Self {
Self {
width,
height,
scroll_top: Pixels::zero(),
scroll_left: Pixels::zero(),
}
}
/// Width of the viewport. When rendering, it's assumed that the UI
/// element is this wide.
pub fn width(&self) -> Pixels {
self.width
}
/// Height of the viewport. When rendering, it's assumed that the UI element
/// is this tall.
pub fn height(&self) -> Pixels {
self.height
}
/// The current vertical scroll position of the viewport.
pub fn scroll_top(&self) -> Pixels {
self.scroll_top
}
/// The current horizontal scroll position of the viewport.
pub fn scroll_left(&self) -> Pixels {
self.scroll_left
}
/// Vertically scroll by `delta` pixels. Scrolling is capped at `content_height`,
/// which should be the height of the buffer content.
///
/// Returns whether the view should be re-rendered.
pub(super) fn scroll(&mut self, delta: Pixels, content_height: Pixels) -> bool {
self.scroll_to(self.scroll_top - delta, content_height)
}
pub(super) fn scroll_horizontally(&mut self, delta: Pixels, content_width: Pixels) -> bool {
self.scroll_horizontally_to(self.scroll_left - delta, content_width)
}
/// Scroll to the given `scroll_top`, clamped to the end of the buffer.
///
/// Returns whether or not the view needs to be re-rendered.
pub(super) fn scroll_to(&mut self, scroll_top: Pixels, content_height: Pixels) -> bool {
let scroll_top = self.clamp_scroll_offset(scroll_top, content_height, self.height);
let changed = scroll_top.approx_ne(self.scroll_top, UNIT_MARGIN);
if changed {
self.scroll_top = scroll_top;
}
changed
}
pub(super) fn scroll_horizontally_to(
&mut self,
scroll_left: Pixels,
content_width: Pixels,
) -> bool {
let scroll_left = self.clamp_scroll_offset(scroll_left, content_width, self.width);
let changed = scroll_left.approx_ne(self.scroll_left, UNIT_MARGIN);
if changed {
self.scroll_left = scroll_left;
}
changed
}
/// Set the scroll position to an exact location.
#[cfg(test)]
pub(super) fn set_scroll_top(&mut self, scroll_top: Pixels) {
self.scroll_top = scroll_top;
}
/// Notifies the viewport model that the content height has changed, which
/// affects the range of valid scroll positions.
///
/// Returns whether the view should be re-rendered.
pub(super) fn update_content_height(&mut self, content_height: Pixels) -> bool {
// A scroll of 0 will reapply the clamping logic to ensure the scroll
// position is still in bounds.
self.scroll(Pixels::zero(), content_height)
}
pub(super) fn update_content_width(&mut self, content_width: Pixels) -> bool {
// A scroll of 0 will reapply the clamping logic to ensure the scroll
// position is still in bounds.
self.scroll_horizontally(Pixels::zero(), content_width)
}
pub(super) fn autoscroll(
&mut self,
item_start: Vector2F,
item_end: Vector2F,
content_height: Pixels,
content_width: Pixels,
should_autoscroll_horizontally: bool,
) -> bool {
let mut changed = false;
if should_autoscroll_horizontally {
if (item_start.x() - HORIZONTAL_SCROLL_MARGIN).into_pixels() < self.scroll_left {
changed = self.scroll_horizontally(
self.scroll_left - item_start.x().into_pixels()
+ AUTO_SCROLL_MARGIN.into_pixels(),
content_width,
) || changed;
} else if (item_end.x() + HORIZONTAL_SCROLL_MARGIN).into_pixels()
> self.scroll_left + self.width
{
changed = self.scroll_horizontally(
self.scroll_left - item_end.x().into_pixels() + self.width
- AUTO_SCROLL_MARGIN.into_pixels(),
content_width,
) || changed;
}
}
if item_start.y().into_pixels() < self.scroll_top {
// The position we want to scroll to is `item_start - AUTO_SCROLL_MARGIN.into_pixels()`.
changed = self.scroll(
self.scroll_top - item_start.y().into_pixels() + AUTO_SCROLL_MARGIN.into_pixels(),
content_height,
) || changed;
} else if item_end.y().into_pixels() > self.scroll_top + self.height {
// The position we want to scroll to is `item_end - self.height + AUTO_SCROLL_MARGIN.into_pixels()`.
changed = self.scroll(
self.scroll_top - item_end.y().into_pixels() + self.height
- AUTO_SCROLL_MARGIN.into_pixels(),
content_height,
) || changed;
}
changed
}
/// Clamps a scroll position to a valid value. The scroll top must be positive,
/// and is at most the content height minus the viewport height. The viewport
/// is scrolled all the way to the top if the scroll position is 0 and
/// all the way to the bottom if the last viewport's worth of content is
/// visible.
fn clamp_scroll_offset(
&self,
scroll_top: Pixels,
content_height: Pixels,
viewport_height: Pixels,
) -> Pixels {
scroll_top
.min(content_height - viewport_height)
.max(Pixels::zero())
}
/// Calculates the viewport size given layout constraints.
///
/// Because we do not have mutable model access when laying out UI elements,
/// size changes are handled in two steps:
/// 1. [`crate::render::element::RichTextElement`] calls `viewport_size` as
/// part of its `layout` implementation.
/// 2. `RichTextElement` then updates the model with the size it computed
/// in `after_layout`. Since `after_layout` runs before painting and
/// event handling, the model still has enough information to viewport,
/// hit-test, and scroll.
pub(in crate::render) fn viewport_size(
&self,
constraint: SizeConstraint,
size_buffer: Vector2F,
max_width: Option<Pixels>,
) -> SizeInfo {
// TODO(ben): We should have a minimum soft-wrap width. If the constraint's
// maximum size is below this, we start horizontal scrolling rather
// than trying to soft-wrap further.
let mut max_constraint = constraint.max;
if let Some(max_width) = max_width {
max_constraint.set_x(constraint.max.x().min(max_width.as_f32()));
}
let content_constraint = SizeConstraint::new(
(constraint.min - size_buffer).max(Vector2F::zero()),
(max_constraint - size_buffer).max(Vector2F::zero()),
);
let width = content_constraint.max.x();
let height = content_constraint.max.y();
let needs_layout = width.approx_ne(self.width.as_f32(), UNIT_MARGIN);
SizeInfo {
viewport_size: vec2f(width, height),
needs_layout,
}
}
/// Save the viewport size that was calculated by a call to [`viewport_size`]
/// during layout. This should only be called by [`crate::render::element::RichTextElement`],
/// otherwise there's no guarantee that content is soft-wrapped to the correct bounds.
///
/// This may also adjust the scroll position, if it's not valid in the new viewport size.
pub(super) fn set_size(
&mut self,
size: Vector2F,
content_width: Pixels,
content_height: Pixels,
) {
self.width = size.x().into_pixels();
self.height = size.y().into_pixels();
// If set_size is called, the view is already being re-rendered, so we can ignore the
// return value of update_content_height.
self.update_content_height(content_height);
self.update_content_width(content_width);
}
}
impl<'a> ViewportIterator<'a> {
/// Begin an iterator over the current viewport.
pub(super) fn new(
content: &'a SumTree<BlockItem>,
scroll_top: Pixels,
viewport_height: Pixels,
viewport_width: Pixels,
) -> Self {
let mut cursor = content.cursor();
cursor.seek_clamped(&scroll_top.into(), SeekBias::Left);
Self {
cursor,
content_start: scroll_top,
content_end: scroll_top + viewport_height,
max_width: viewport_width,
}
}
}
impl<'a> Iterator for ViewportIterator<'a> {
type Item = (ViewportItem, &'a BlockItem);
fn next(&mut self) -> Option<Self::Item> {
let item = self.cursor.positioned_item()?;
// Stop rendering once the current item is completely outside the viewport.
if item.start_y_offset > self.content_end {
return None;
}
self.cursor.next();
let spacing = item.item.spacing();
let content_width = self.max_width - spacing.x_axis_offset();
let viewport_item = ViewportItem {
viewport_offset: item.start_y_offset - self.content_start,
content_offset: item.start_y_offset,
content_size: vec2f(content_width.as_f32(), item.item.content_height().as_f32()),
spacing,
block_offset: item.start_char_offset,
};
Some((viewport_item, item.item))
}
}
impl ViewportItem {
/// The block backing this viewport item.
pub fn block_offset(&self) -> CharOffset {
self.block_offset
}
pub fn height(&self) -> f64 {
// We sometimes encounter floating point errors when since we are seeking exactly on the edge of
// a block item. Add a small buffer here so we could consistently seek to the right element.
self.content_offset.as_f32() as f64 + 0.1
}
/// The content bounds of this item (see [`bounds::content_box`]).
pub fn content_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::content_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
/// The visible bounds of this item (see [`bounds::visible_box`]).
pub fn visible_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::visible_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
/// The reserved bounds of this item (see [`bounds::reserved_box`]).
pub fn reserved_bounds(&self, ctx: &RenderContext) -> RectF {
ctx.content_rect_to_screen(bounds::reserved_box(
self.content_offset,
self.content_size,
&self.spacing,
))
}
}
#[macro_export]
macro_rules! extract_block {
($viewport_item:expr, $content:expr, $match:pat => $value:expr) => {{
let offset = $viewport_item.block_offset();
match $content.block_at_offset(offset) {
Some(block) => match (&block, block.item) {
$match => $value,
other => {
log::trace!("Unexpected block {other:?} at {}", offset);
return;
}
},
None => return,
}
}};
}
@@ -0,0 +1,189 @@
use itertools::Itertools;
use sum_tree::SumTree;
use warpui::{
SizeConstraint,
geometry::vector::vec2f,
units::{IntoPixels, Pixels},
};
use crate::render::model::{
RenderState,
test_utils::{TEST_STYLES, mock_paragraph},
};
use super::ViewportState;
#[test]
fn test_viewport_offsets() {
let mut render_state =
RenderState::new_for_test(TEST_STYLES, 20.0.into_pixels(), 180.0.into_pixels());
let mut content = SumTree::new();
content.push(mock_paragraph(10., 100., 1));
content.push(mock_paragraph(60., 100., 1));
content.push(mock_paragraph(100., 100., 2));
content.push(mock_paragraph(30., 100., 3));
content.push(mock_paragraph(80., 100., 1));
render_state.set_content(content);
// Double-check the heights with margins+padding, as later tests rely on them.
let heights = render_state
.content
.borrow()
.items()
.iter()
.map(|item| item.height().as_f32())
.collect_vec();
assert_eq!(heights, vec![32., 68., 108., 38., 88., 32.]);
let content = render_state.content();
let offsets = content
.viewport_items(
180.0.into_pixels(),
render_state.viewport().width(),
40.0.into_pixels(),
)
.map(|(item, _)| (item.viewport_offset.as_f32(), item.block_offset.as_usize()))
.collect_vec();
// Each item should be painted starting at the sum of all previous heights,
// offset by scroll_top
assert_eq!(
offsets,
vec![
// The first item is fully above the viewport.
// The second item is slightly above the viewport.
(-8., 1),
// The third is fully within the viewport
(60., 2),
// The fourth is slightly past the viewport, and cut off.
(168., 4)
] // The fifth item is fully after the viewport.
);
}
#[test]
fn test_viewport_item_to_block() {
let mut render_state =
RenderState::new_for_test(TEST_STYLES, 100.0.into_pixels(), 160.0.into_pixels());
let mut content = SumTree::new();
content.push(mock_paragraph(80., 100., 10));
content.push(mock_paragraph(20., 100., 3));
render_state.set_content(content);
let viewport_items = {
let content = render_state.content();
content
.viewport_items(
160.0.into_pixels(),
render_state.viewport().width(),
0.0.into_pixels(),
)
.map(|(item, _)| item)
.collect_vec()
};
let content = render_state.content();
let block0 = content
.block_at_offset(viewport_items[0].block_offset())
.expect("Block should exist");
assert_eq!(block0.start_char_offset, 0.into());
let block1 = content
.block_at_offset(viewport_items[1].block_offset())
.expect("Block should exist");
assert_eq!(block1.start_char_offset, 10.into());
drop(content);
// Now, invalidate the items by pushing new content. They should detect this and fail.
let mut new_tree = SumTree::new();
new_tree.push(mock_paragraph(40., 100., 4));
new_tree.push_tree(render_state.content.borrow().clone());
render_state.set_content(new_tree);
let content = render_state.content();
// There's still _a_ block at char offset 0, which is what's returned here. This is fine, since
// the check in ViewportItem::block is a fallback in case of programmer error.
assert!(
content
.block_at_offset(viewport_items[0].block_offset())
.is_some()
);
// However, the next item no longer has a backing block.
assert!(
content
.block_at_offset(viewport_items[1].block_offset())
.is_none()
);
}
#[test]
fn test_scroll_bounds() {
let content_height = 32.0.into_pixels();
let mut state = ViewportState::new(8.0.into_pixels(), 16.0.into_pixels());
// Scroll down within the document.
assert!(state.scroll((-6.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 6.);
// Now, scroll up, but past the beginning of the document. The scroll_top
// should clamp at 0.
assert!(state.scroll(100.0.into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 0.);
// Now, scroll back down.
assert!(state.scroll((-12.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 12.);
// We can keep scrolling, but it's clamped to the last viewport of the document.
assert!(state.scroll((-100.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 16.);
// If we try to scroll more, it has no effect.
assert!(!state.scroll((-10.0).into_pixels(), content_height));
assert_eq!(state.scroll_top.as_f32(), 16.);
}
#[test]
fn test_viewport_width_change() {
let state = ViewportState::new(100.0.into_pixels(), 500.0.into_pixels());
// Shrink the viewport by 20 pixels horizontally. It should use the maximum space available,
// and need layout because of the size change.
let size_info = state.viewport_size(
SizeConstraint::new(vec2f(20., 100.), vec2f(80., 500.)),
vec2f(0., 0.),
None,
);
assert!(size_info.needs_layout);
assert_eq!(size_info.viewport_size, vec2f(80., 500.));
}
#[test]
fn test_viewport_height_change() {
let content_height = 300.0.into_pixels();
let content_width = 100.0.into_pixels();
let mut state = ViewportState::new(100.0.into_pixels(), 200.0.into_pixels());
// Scroll the viewport.
state.scroll(-(50.0.into_pixels()), content_height);
assert_eq!(state.scroll_top, 50.0.into_pixels());
// Resize the viewport such that it no longer has a scrollbar.
state.set_size(vec2f(100., 400.), content_width, content_height);
assert_eq!(state.scroll_top, Pixels::zero());
// Resize the viewport to need scrolling again. This won't autoscroll, however.
state.set_size(vec2f(100., 100.), content_width, content_height);
assert_eq!(state.scroll_top, Pixels::zero());
// Scroll, and then shrink the viewport further. This should preserve the scroll position.
state.scroll(-(50.0.into_pixels()), content_height);
state.set_size(vec2f(100., 80.), content_width, content_height);
assert_eq!(state.scroll_top, 50.0.into_pixels());
// The scroll_top cannot be past the last viewport of content. If the viewport extends such
// that the scroll position is invalid, but a scrollbar is still needed, we'll scroll up slightly.
state.set_size(vec2f(100., 260.), content_width, content_height);
assert_eq!(state.scroll_top, 40.0.into_pixels());
}